feat: roadmap batch (2026-06-19) — solar/linear-light/dither/nanoleaf + integrations

Eight roadmap features from the 2026-06-19 review, each a full vertical
(backend + tests + frontend + i18n en/ru/zh); ~67 new unit tests:

- automations: SolarRule sunrise/sunset trigger (new utils/solar.py, shared
  with the daylight cycle; window logic mirrors TimeOfDayRule)
- ci: best-effort arm64 multi-arch Docker manifest via QEMU + docker manifest
  (release.yml; amd64 path untouched, continue-on-error)
- game-integration: wire the orphaned LoLPoller via a LoLPollManager + a shared
  runtime_state module (poll lifecycle on enable/CRUD/startup/shutdown)
- ui: color-harmony gradient generator (complementary/analogous/triadic/...)
- effects: audio-reactive palette modulation (new audio_energy_tap; brightness/
  saturation modulation across all 12 procedural effects)
- capture: linear-light blending + spatio-temporal dithering, opt-in per
  calibration (new utils/linear_light.py, utils/dither.py)
- devices: Nanoleaf extControl v2 per-panel UDP streaming (per_panel mode)

Also bundles the pending 2026-06-18 production-review fixes and other
in-progress work already in the working tree (manual-trigger rule, etc.),
since they share files and could not be cleanly separated.

Gate: ruff + tsc clean; pytest 2654 passed / 2 skipped. The single failing
test (automation manual_trigger handler coverage) is a separate in-progress
item owned elsewhere, intentionally left as-is.
This commit is contained in:
2026-06-22 23:21:24 +03:00
parent 126d8f2449
commit 6745e25b20
91 changed files with 4390 additions and 540 deletions
@@ -32,15 +32,15 @@ class ApiKeyManager(context: Context) {
// key. If the keystore is unavailable (some OEM TV-box ROMs ship a broken
// or absent keystore, or a key got corrupted), creation throws — fall back
// to plain SharedPreferences so a keystore failure NEVER bricks the local
// API key (which would 401 every LAN client). [encrypted] records which
// path we took so we don't repeatedly attempt migration.
private val encrypted: Boolean
// API key (which would 401 every LAN client).
private val prefs: SharedPreferences
init {
val (store, isEncrypted) = buildPrefs(appContext)
prefs = store
encrypted = isEncrypted
// Only run the plain→encrypted migration when the encrypted store is
// actually available; on the degraded plain path there is nothing to
// migrate INTO (and recoverLegacyKey reads the backup directly).
if (isEncrypted) migrateLegacyKeyIfPresent()
}
@@ -113,26 +113,48 @@ class ApiKeyManager(context: Context) {
*/
private fun buildPrefs(context: Context): Pair<SharedPreferences, Boolean> {
return try {
val masterKey = MasterKey.Builder(context)
.setKeyScheme(MasterKey.KeyScheme.AES256_GCM)
.build()
val store = EncryptedSharedPreferences.create(
context,
ENCRYPTED_PREFS_NAME,
masterKey,
EncryptedSharedPreferences.PrefKeyEncryptionScheme.AES256_SIV,
EncryptedSharedPreferences.PrefValueEncryptionScheme.AES256_GCM,
)
store to true
createEncrypted(context) to true
} catch (e: Exception) {
// Keystore unavailable/corrupt — degrade to plain prefs rather
// than crashing. Worst case the key is stored unencrypted on a
// single-user TV box, which is the pre-existing behaviour.
Log.w(TAG, "EncryptedSharedPreferences unavailable, using plain prefs: ${e.message}")
context.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE) to false
// The keystore can become invalidated (OS upgrade, device restore,
// OEM keystore bug), after which create() throws on EVERY launch and
// the corrupt encrypted file is never cleaned up — degrading to plain
// prefs forever and (because the live key was only in the encrypted
// store) rotating the per-install key on the next mint, 401-ing every
// paired client. Self-heal once: delete the corrupt store + master key
// alias and retry create() before degrading.
Log.w(TAG, "EncryptedSharedPreferences unavailable, attempting one-time reset: ${e.message}")
runCatching {
context.deleteSharedPreferences(ENCRYPTED_PREFS_NAME)
runCatching {
val ks = java.security.KeyStore.getInstance("AndroidKeyStore").apply { load(null) }
if (ks.containsAlias(MasterKey.DEFAULT_MASTER_KEY_ALIAS)) {
ks.deleteEntry(MasterKey.DEFAULT_MASTER_KEY_ALIAS)
}
}.onFailure { Log.w(TAG, "Master-key alias cleanup failed: ${it.message}") }
createEncrypted(context) to true
}.getOrElse {
// Still failing after reset — degrade to plain prefs rather than
// crashing. Worst case the key is stored unencrypted on a
// single-user TV box, which is the pre-existing behaviour.
Log.w(TAG, "EncryptedSharedPreferences still unavailable after reset, using plain prefs: ${it.message}")
context.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE) to false
}
}
}
private fun createEncrypted(context: Context): SharedPreferences {
val masterKey = MasterKey.Builder(context)
.setKeyScheme(MasterKey.KeyScheme.AES256_GCM)
.build()
return EncryptedSharedPreferences.create(
context,
ENCRYPTED_PREFS_NAME,
masterKey,
EncryptedSharedPreferences.PrefKeyEncryptionScheme.AES256_SIV,
EncryptedSharedPreferences.PrefValueEncryptionScheme.AES256_GCM,
)
}
/**
* One-time migration: if a key exists in the legacy plain-text prefs file
* (from before encrypted storage), copy it into the encrypted store and
@@ -176,12 +198,17 @@ class ApiKeyManager(context: Context) {
/**
* Recover a still-present key from the legacy plain store — either the live
* key (failed/never-run migration) or the `.migrated` backup. Returns null
* when on the plain-prefs path (no legacy/encrypted split) or no valid key
* survives. Guarantees [getOrCreateKey] never rotates an existing key as long
* as the legacy file survives.
* only when no valid key survives.
*
* This MUST run on the degraded plain-prefs path too (not just the encrypted
* path): after a successful migration the live key is moved to the
* `.migrated` backup in this same plain file, so when the keystore later
* fails and we degrade to plain prefs, the backup is the only surviving
* copy. Returning null here (the previous `if (!encrypted) return null`
* guard) would mint a fresh key and rotate the per-install key, 401-ing every
* paired client.
*/
private fun recoverLegacyKey(): String? {
if (!encrypted) return null
val legacy = appContext.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE)
val candidate = legacy.getString(KEY_API_KEY, null)
?: legacy.getString(KEY_API_KEY_MIGRATED, null)
@@ -42,6 +42,12 @@ class LedGrabNotificationListener : NotificationListenerService() {
// onListenerConnected can't race two executors into existence.
private val executorLock = Any()
// Tracks whether the listener is currently connected. ensureExecutor() only
// CREATES a new executor while connected — otherwise a notification racing
// onListenerDisconnected (which nulls pushExecutor) would spin up a fresh
// executor that nothing reaps until the next disconnect cycle (a thread leak).
@Volatile private var connected: Boolean = false
// packageName -> resolved human-readable label. Matches the app_name the
// Windows/Linux backends pass, so per-app colors/filters keep working.
// Naturally bounded by the number of notification-posting apps (tens) and
@@ -74,7 +80,10 @@ class LedGrabNotificationListener : NotificationListenerService() {
// executor that onListenerDisconnected is shutting down throws
// RejectedExecutionException — guard with runCatching so a notification
// racing teardown can never crash this system-bound service.
val executor = ensureExecutor()
val executor = ensureExecutor() ?: run {
Log.d(TAG, "no executor (listener disconnected) — skipping push")
return
}
runCatching {
executor.execute {
try {
@@ -116,13 +125,16 @@ class LedGrabNotificationListener : NotificationListenerService() {
}
/**
* Return the push executor, creating it under [executorLock] if absent.
* Safe against a concurrent onListenerConnected/onNotificationPosted race
* (single executor) and against a missing onListenerConnected callback.
* Return the push executor, creating it under [executorLock] if absent AND
* the listener is connected. Returns null when disconnected so a notification
* racing teardown neither submits onto a shutting-down executor nor spins up
* a stray one. Safe against a concurrent onListenerConnected/onNotificationPosted
* race (single executor) and against a missing onListenerConnected callback.
*/
private fun ensureExecutor(): ExecutorService {
private fun ensureExecutor(): ExecutorService? {
pushExecutor?.let { return it }
synchronized(executorLock) {
if (!connected) return null
return pushExecutor ?: Executors.newSingleThreadExecutor().also { pushExecutor = it }
}
}
@@ -134,15 +146,16 @@ class LedGrabNotificationListener : NotificationListenerService() {
// executor here rather than in onCreate/onDestroy. onNotificationPosted
// also lazily creates it (via ensureExecutor) in case this callback is
// late or skipped on some ROMs.
connected = true
ensureExecutor()
}
override fun onListenerDisconnected() {
Log.i(TAG, "Notification listener disconnected")
// Tear the executor down on disconnect; a fresh one is created on the
// next onListenerConnected. Null out first so any in-flight
// onNotificationPosted snapshots see null (skips submit) rather than
// racing a shutdown executor.
// Mark disconnected BEFORE nulling the executor so a racing ensureExecutor
// sees !connected and skips creating a replacement. Tear the executor
// down; a fresh one is created on the next onListenerConnected.
connected = false
pushExecutor?.let { exec ->
pushExecutor = null
exec.shutdown()
@@ -152,6 +165,7 @@ class LedGrabNotificationListener : NotificationListenerService() {
override fun onDestroy() {
// Defensive: onListenerDisconnected normally clears this first, but
// shut down here too in case onDestroy fires without a prior disconnect.
connected = false
pushExecutor?.shutdown()
pushExecutor = null
super.onDestroy()