17dd2e02ba
Multi-dimension review of v0.8.2. Excludes the deliberately deferred default_config.yaml weak-default-key item (C1). Backend: - calibration: create_default_calibration no longer exceeds led_count for small odd counts (bounded trim + regression test) - game-integration: generic webhook now requires auth_token; constant-time compare_digest in all adapters; per-IP failed-auth rate limit on the ingest route; auth_token encrypted at rest via secret_box (migration-safe) - playlist engine: serialize _state/_task under the lifecycle lock to close a delete-mid-play race (+ concurrency tests) - main: stop the calibration session on shutdown (restore prior target) - home_assistant: validate HA host via the LAN classifier on create/update - perf: drop slow preview-WS clients instead of blocking the send loop; cache composite full-strip resize linspaces; effect_stream lava reuses scratch Frontend: - setup/auto-calibration wizard: guard _state after awaits (cancel-safe), await session teardown before output start, busy-gate skip-calibration, manual display input keeps focus, move focus on step change - calibration: destroy EntitySelect on modal close - color-strips test: dirty-flag-gated render + cached ctx/ImageData - a11y/TV: focus-visible for new wizard/auto-cal/corner controls, aria-labels on the spatial corner/edge picker; theme-aware syntax tokens; dead/undefined CSS tokens removed; .modal-error styled; i18n titles (en/ru/zh) Android: - ApiKeyManager: EncryptedSharedPreferences with verified, data-safe legacy migration that never rotates an existing key - CaptureService: validate MediaProjection token before promoting; satisfy the startForeground 5s contract on the bail path - NotificationListener: connection-scoped executor with lazy fallback - BLE: request BLUETOOTH_SCAN/CONNECT at runtime + guard handler-thread SecurityExceptions - Root: cancellation-aware su grant probe Adds 14 tests. Gate: ruff + tsc 5.9.3 + esbuild + pytest (2185 passed) + compileDebugKotlin all green.
221 lines
9.7 KiB
Kotlin
221 lines
9.7 KiB
Kotlin
import java.util.concurrent.TimeUnit
|
|
|
|
plugins {
|
|
id("com.android.application")
|
|
id("org.jetbrains.kotlin.android")
|
|
id("com.chaquo.python")
|
|
}
|
|
|
|
// Derive versionCode from git commit count so sideload/Play upgrades
|
|
// always see a strictly-greater value without anyone remembering to
|
|
// bump by hand. ANDROID_VERSION_CODE env var wins for CI pipelines
|
|
// that prefer explicit values; fallback is `1` when neither is
|
|
// available (e.g. building from a tarball with no .git).
|
|
val ledgrabVersionCode: Int = run {
|
|
System.getenv("ANDROID_VERSION_CODE")?.toIntOrNull()?.let { return@run it }
|
|
try {
|
|
val process = ProcessBuilder("git", "rev-list", "--count", "HEAD")
|
|
.directory(rootDir)
|
|
.redirectErrorStream(true)
|
|
.start()
|
|
if (process.waitFor(5, TimeUnit.SECONDS) && process.exitValue() == 0) {
|
|
process.inputStream.bufferedReader().readText().trim().toIntOrNull() ?: 1
|
|
} else {
|
|
1
|
|
}
|
|
} catch (_: Exception) {
|
|
1
|
|
}
|
|
}
|
|
|
|
android {
|
|
namespace = "com.ledgrab.android"
|
|
// 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 = 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.8.2"
|
|
|
|
// 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 {
|
|
// 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
|
|
}
|
|
}
|
|
|
|
// Per-ABI APK splits — reduces download size by ~60% vs universal APK.
|
|
// 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
|
|
}
|
|
}
|
|
|
|
// Signing config from env vars (CI) — only registered when all four are set.
|
|
// Local release builds fall back to the debug signing config.
|
|
val ciKeystorePath = System.getenv("ANDROID_KEYSTORE_PATH")
|
|
val ciKeystorePassword = System.getenv("ANDROID_KEYSTORE_PASSWORD")
|
|
val ciKeyAlias = System.getenv("ANDROID_KEY_ALIAS")
|
|
val ciKeyPassword = System.getenv("ANDROID_KEY_PASSWORD")
|
|
val hasCiSigning = listOf(ciKeystorePath, ciKeystorePassword, ciKeyAlias, ciKeyPassword)
|
|
.all { !it.isNullOrBlank() } && file(ciKeystorePath!!).exists()
|
|
|
|
signingConfigs {
|
|
if (hasCiSigning) {
|
|
create("release") {
|
|
storeFile = file(ciKeystorePath!!)
|
|
storePassword = ciKeystorePassword
|
|
keyAlias = ciKeyAlias
|
|
keyPassword = ciKeyPassword
|
|
}
|
|
}
|
|
}
|
|
|
|
buildTypes {
|
|
release {
|
|
// TODO(minify): keep R8 disabled until Chaquopy reflection is
|
|
// verified end-to-end. Chaquopy resolves Kotlin classes & static
|
|
// methods (PythonBridge, UsbSerialBridge, Root) by name from
|
|
// Python via PyObject — silent stripping breaks the app at
|
|
// runtime, after release. proguard-rules.pro contains keep
|
|
// rules covering the known entry points, but until we have
|
|
// a release smoke test that exercises every PyObject path we
|
|
// do NOT ship a minified release.
|
|
isMinifyEnabled = false
|
|
proguardFiles(
|
|
getDefaultProguardFile("proguard-android-optimize.txt"),
|
|
"proguard-rules.pro",
|
|
)
|
|
// 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."
|
|
)
|
|
}
|
|
}
|
|
}
|
|
|
|
compileOptions {
|
|
sourceCompatibility = JavaVersion.VERSION_17
|
|
targetCompatibility = JavaVersion.VERSION_17
|
|
}
|
|
|
|
kotlinOptions {
|
|
jvmTarget = "17"
|
|
}
|
|
}
|
|
|
|
chaquopy {
|
|
defaultConfig {
|
|
version = "3.11"
|
|
|
|
pip {
|
|
// Pre-built wheels directory (for pydantic-core etc.) as an
|
|
// absolute file:// URI with forward slashes. Pip requires
|
|
// exactly three slashes after `file:` for an empty-host local
|
|
// path; the path-normalization differs by platform:
|
|
// Windows: absolute path is "C:/..." → prefix "file:///"
|
|
// Linux: absolute path is "/..." → prefix "file://"
|
|
// Using a fixed "file:///" prefix on both made CI produce
|
|
// "file:////workspace/…", which pip rejects as non-local.
|
|
val wheelsPath = rootDir.absolutePath.replace("\\", "/")
|
|
val wheelsUrlPrefix = if (wheelsPath.startsWith("/")) "file://" else "file:///"
|
|
options("--find-links", "$wheelsUrlPrefix$wheelsPath/wheels/")
|
|
|
|
// ── Android-compatible dependencies ─────────────────
|
|
// Listed explicitly because pyproject.toml includes
|
|
// desktop-only packages with no Android wheels.
|
|
// See CLAUDE.md "Android Dependency Sync" for policy.
|
|
install("fastapi")
|
|
install("uvicorn") // without [standard] — no uvloop/httptools
|
|
install("httpx")
|
|
install("numpy")
|
|
install("pydantic") // needs pydantic-core wheel in wheels/
|
|
install("pydantic-settings")
|
|
install("PyYAML")
|
|
install("structlog")
|
|
install("python-json-logger")
|
|
install("python-dateutil")
|
|
install("python-multipart")
|
|
install("jinja2")
|
|
install("zeroconf")
|
|
install("aiomqtt")
|
|
install("openrgb-python")
|
|
// opencv-python-headless: no cp311 Android wheel on Chaquopy.
|
|
// LedGrab's cv2 usage is guarded with try/except ImportError
|
|
// and falls back to numpy/Pillow alternatives on Android.
|
|
install("Pillow")
|
|
install("websockets")
|
|
install("cryptography") // AES-GCM secret-box for HA/MQTT credentials
|
|
}
|
|
}
|
|
}
|
|
|
|
// LedGrab Python source is included via a directory junction:
|
|
// android/app/src/main/python/ledgrab -> server/src/ledgrab
|
|
// This is the standard Chaquopy way to include local Python packages.
|
|
// Create the junction (run from repo root, no admin needed):
|
|
// cmd /c "mklink /J android\app\src\main\python\ledgrab server\src\ledgrab"
|
|
|
|
|
|
dependencies {
|
|
implementation("androidx.core:core-ktx:1.13.1")
|
|
implementation("androidx.appcompat:appcompat:1.7.0")
|
|
implementation("androidx.leanback:leanback:1.0.0")
|
|
implementation("com.google.android.material:material:1.12.0")
|
|
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")
|
|
// EncryptedSharedPreferences (Android Keystore-backed) for the per-install
|
|
// server API key (see ApiKeyManager). Falls back to plain SharedPreferences
|
|
// when the keystore is unavailable.
|
|
implementation("androidx.security:security-crypto:1.1.0-alpha06")
|
|
// USB-serial drivers (CH340, CP2102, FTDI, Prolific, CDC-ACM) for
|
|
// driving Adalight/AmbiLED controllers plugged into Android TV boxes.
|
|
implementation("com.github.mik3y:usb-serial-for-android:3.8.1")
|
|
}
|