Files
ledgrab/android/app/build.gradle.kts
T
alexei.dolgolyov b3775b2f98 feat(android): boot-time autostart, capture watchdog, versionCode from git
Boot-time startup so LedGrab has display capture and control without user
interaction on rooted TV boxes. Also folds in a batch of review findings
from the Android package audit.

Autostart
- BootReceiver fires on BOOT_COMPLETED / LOCKED_BOOT_COMPLETED /
  MY_PACKAGE_REPLACED, gated by AutostartPrefs and Root.looksRooted().
  Dispatches CaptureService.createRootIntent via
  ContextCompat.startForegroundService. Unrooted devices are a no-op
  because MediaProjection consent cannot be bypassed silently.
- AutostartPrefs: thin SharedPreferences wrapper, defaults to enabled.
  Exposed as a CheckBox on the stopped panel; greyed out when not rooted.
- Manifest: RECEIVE_BOOT_COMPLETED, REQUEST_IGNORE_BATTERY_OPTIMIZATIONS,
  WAKE_LOCK permissions + the new BootReceiver.
- MainActivity prompts for battery-optimization exemption on first opt-in
  so Doze/App Standby doesn't kill the FG service on phones.

Service stability
- onStartCommand now flips isRunning only after startForeground succeeds
  (was stuck=true forever if the FG transition threw) and resets on
  exception. Returns START_REDELIVER_INTENT for root mode so the OS can
  restart the service with the original intent (no consent token to
  invalidate); MediaProjection mode keeps START_NOT_STICKY.
- Watchdog coroutine monitors RootScreenrecord.framesDelivered. Respawns
  the pipeline on stall (reusing the existing Python bridge — no server
  restart), caps at 3 consecutive restarts before giving up.
- RootScreenrecord.framesDelivered is now an AtomicInteger, exposed as a
  public property for the watchdog.
- ScreenCapture takes an onProjectionStopped lambda; when the user taps
  the system Cast/Screen-capture stop banner, the whole service is torn
  down instead of leaving a stale FG notification.
- MainActivity's two startForegroundService calls switch to
  ContextCompat.startForegroundService, clearing pre-existing NewApi lint
  errors (minSdk=24 < API 26 native method).

Build
- versionCode derived from git rev-list --count HEAD (or the
  ANDROID_VERSION_CODE env var for CI). Was pinned to 1 — sideload
  upgrades were silently refusing to install.
- New i18n strings (autostart_label, autostart_unavailable, version_prefix)
  in en/ru/zh; version_text now uses the resource instead of string
  concat.

TODO.md: new "Android Autostart on Boot" section tracking done/pending
items; real-hardware verification on a Magisk'd TV box is the remaining
checkbox.
2026-04-21 18:53:27 +03:00

176 lines
6.9 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"
compileSdk = 34
defaultConfig {
applicationId = "com.ledgrab.android"
minSdk = 24 // Android 7.0 — covers nearly all TV boxes
targetSdk = 34
// 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.3.0"
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")
}
}
// 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 {
isEnable = true
reset()
include("arm64-v8a", "x86_64", "x86")
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",
)
signingConfig = if (hasCiSigning) {
signingConfigs.getByName("release")
} else {
signingConfigs.getByName("debug")
}
}
}
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.)
// Must use absolute file:// URI with forward slashes
options("--find-links", "file:///${rootDir.absolutePath.replace("\\", "/")}/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")
// 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
// driving Adalight/AmbiLED controllers plugged into Android TV boxes.
implementation("com.github.mik3y:usb-serial-for-android:3.8.1")
}