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:
@@ -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()
|
||||
|
||||
Reference in New Issue
Block a user