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.9.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 { // 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") }