fix: resolve comprehensive review findings (security, concurrency, perf, Android, UI)
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.
This commit is contained in:
@@ -210,6 +210,10 @@ dependencies {
|
||||
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")
|
||||
|
||||
@@ -14,7 +14,8 @@
|
||||
<uses-permission android:name="android.permission.BLUETOOTH_SCAN"
|
||||
android:usesPermissionFlags="neverForLocation"
|
||||
tools:targetApi="s" />
|
||||
<uses-permission android:name="android.permission.BLUETOOTH_CONNECT" />
|
||||
<uses-permission android:name="android.permission.BLUETOOTH_CONNECT"
|
||||
tools:targetApi="s" />
|
||||
|
||||
<!-- BLE hardware — required=false so non-BT boxes still install. -->
|
||||
<uses-feature
|
||||
|
||||
@@ -1,7 +1,10 @@
|
||||
package com.ledgrab.android
|
||||
|
||||
import android.content.Context
|
||||
import android.content.SharedPreferences
|
||||
import android.util.Log
|
||||
import androidx.security.crypto.EncryptedSharedPreferences
|
||||
import androidx.security.crypto.MasterKey
|
||||
import java.security.SecureRandom
|
||||
|
||||
/**
|
||||
@@ -23,8 +26,23 @@ import java.security.SecureRandom
|
||||
*/
|
||||
class ApiKeyManager(context: Context) {
|
||||
|
||||
private val prefs = context.applicationContext
|
||||
.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE)
|
||||
private val appContext = context.applicationContext
|
||||
|
||||
// Prefer Android-Keystore-backed EncryptedSharedPreferences for the API
|
||||
// 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
|
||||
private val prefs: SharedPreferences
|
||||
|
||||
init {
|
||||
val (store, isEncrypted) = buildPrefs(appContext)
|
||||
prefs = store
|
||||
encrypted = isEncrypted
|
||||
if (isEncrypted) migrateLegacyKeyIfPresent()
|
||||
}
|
||||
|
||||
// Once we've materialised a key in this process, cache it so
|
||||
// subsequent reads don't hit prefs and don't risk re-checking
|
||||
@@ -60,6 +78,20 @@ class ApiKeyManager(context: Context) {
|
||||
cached = existing
|
||||
return existing
|
||||
}
|
||||
// Before minting a fresh key, fall back to any key still in the
|
||||
// legacy plain store (covers a failed/partial encrypted migration:
|
||||
// commit() can return false WITHOUT throwing, so migration may have
|
||||
// left the live key only in the legacy file). Rotating the
|
||||
// per-install key would 401 every already-paired client, so we
|
||||
// generate a brand-new key ONLY when no key exists anywhere.
|
||||
recoverLegacyKey()?.let { recovered ->
|
||||
// Best-effort persist into the encrypted store; cache regardless
|
||||
// so we still return the recovered key if the write keeps failing.
|
||||
runCatching { prefs.edit().putString(KEY_API_KEY, recovered).commit() }
|
||||
cached = recovered
|
||||
Log.i(TAG, "Recovered existing API key from legacy storage")
|
||||
return recovered
|
||||
}
|
||||
val generated = generateKey()
|
||||
// commit() (synchronous disk write) on the FIRST write so
|
||||
// the key is durable before MainActivity encodes it into a
|
||||
@@ -74,6 +106,88 @@ class ApiKeyManager(context: Context) {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Build the backing store, preferring EncryptedSharedPreferences. Returns
|
||||
* (store, isEncrypted). Any keystore failure falls back to the plain prefs
|
||||
* file so the local API key is never lost on a broken-keystore device.
|
||||
*/
|
||||
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
|
||||
} 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
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 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
|
||||
* remove the plain copy. Preserves the existing key so already-scanned QR
|
||||
* clients keep working — generating a fresh key here would silently 401
|
||||
* every LAN client (see the Data Migration Policy in CLAUDE.md).
|
||||
*/
|
||||
private fun migrateLegacyKeyIfPresent() {
|
||||
// Don't migrate if the encrypted store already holds a key.
|
||||
if (!prefs.getString(KEY_API_KEY, null).isNullOrEmpty()) return
|
||||
runCatching {
|
||||
val legacy = appContext.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE)
|
||||
val legacyKey = legacy.getString(KEY_API_KEY, null)
|
||||
if (legacyKey != null && legacyKey.length >= MIN_KEY_LENGTH) {
|
||||
// commit() returns false on write failure WITHOUT throwing, so the
|
||||
// runCatching wrapper alone does NOT protect this path. Verify the
|
||||
// encrypted store both committed AND reads back the identical value
|
||||
// before touching the legacy copy — otherwise a silent write
|
||||
// failure could delete the only surviving copy of the key and
|
||||
// rotate it on next launch (401s every paired client — the exact
|
||||
// silent-data-loss the Data Migration Policy forbids).
|
||||
val ok = prefs.edit().putString(KEY_API_KEY, legacyKey).commit()
|
||||
if (ok && prefs.getString(KEY_API_KEY, null) == legacyKey) {
|
||||
// Keep the value as a .migrated backup (don't hard-delete) per
|
||||
// the migration policy; remove only the live legacy key so the
|
||||
// plaintext copy no longer answers reads.
|
||||
legacy.edit()
|
||||
.putString(KEY_API_KEY_MIGRATED, legacyKey)
|
||||
.remove(KEY_API_KEY)
|
||||
.apply()
|
||||
Log.i(TAG, "Migrated API key from plain to encrypted storage")
|
||||
} else {
|
||||
// Leave the legacy key untouched; getOrCreateKey() will recover
|
||||
// it via recoverLegacyKey() rather than minting a fresh one.
|
||||
Log.w(TAG, "Encrypted key write unverified — keeping legacy key, not migrating")
|
||||
}
|
||||
}
|
||||
}.onFailure { Log.w(TAG, "Legacy API key migration failed: ${it.message}") }
|
||||
}
|
||||
|
||||
/**
|
||||
* 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.
|
||||
*/
|
||||
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)
|
||||
return candidate?.takeIf { it.length >= MIN_KEY_LENGTH }
|
||||
}
|
||||
|
||||
private fun generateKey(): String {
|
||||
val bytes = ByteArray(KEY_BYTES)
|
||||
SecureRandom().nextBytes(bytes)
|
||||
@@ -88,7 +202,11 @@ class ApiKeyManager(context: Context) {
|
||||
companion object {
|
||||
private const val TAG = "ApiKeyManager"
|
||||
private const val PREFS_NAME = "ledgrab_auth"
|
||||
private const val ENCRYPTED_PREFS_NAME = "ledgrab_auth_enc"
|
||||
private const val KEY_API_KEY = "api_key"
|
||||
// Backup of a migrated legacy key, kept in the plain store per the
|
||||
// Data Migration Policy (never hard-delete user data on rename/move).
|
||||
private const val KEY_API_KEY_MIGRATED = "api_key_migrated"
|
||||
private const val KEY_BYTES = 32
|
||||
private const val MIN_KEY_LENGTH = 32
|
||||
|
||||
|
||||
@@ -103,12 +103,32 @@ object BleBridge {
|
||||
}
|
||||
|
||||
try {
|
||||
bleHandler.post { scanner.startScan(callback) }
|
||||
// startScan runs on the BLE handler thread; a denied
|
||||
// BLUETOOTH_SCAN throws SecurityException there, which would
|
||||
// crash the whole process (an uncaught exception on a handler
|
||||
// thread is fatal). Catch it inside the posted body and report.
|
||||
bleHandler.post {
|
||||
try {
|
||||
scanner.startScan(callback)
|
||||
} catch (e: SecurityException) {
|
||||
Log.w(TAG, "BLUETOOTH_SCAN permission denied — scan skipped", e)
|
||||
} catch (e: Exception) {
|
||||
Log.w(TAG, "BLE startScan failed: ${e.message}")
|
||||
}
|
||||
}
|
||||
Thread.sleep(timeoutMs)
|
||||
} catch (_: InterruptedException) {
|
||||
Thread.currentThread().interrupt()
|
||||
} finally {
|
||||
try { bleHandler.post { scanner.stopScan(callback) } } catch (_: SecurityException) {}
|
||||
bleHandler.post {
|
||||
try {
|
||||
scanner.stopScan(callback)
|
||||
} catch (e: SecurityException) {
|
||||
Log.w(TAG, "BLUETOOTH_SCAN permission denied — stopScan skipped", e)
|
||||
} catch (e: Exception) {
|
||||
Log.w(TAG, "BLE stopScan failed: ${e.message}")
|
||||
}
|
||||
}
|
||||
}
|
||||
return seen.values.toList()
|
||||
}
|
||||
@@ -136,7 +156,18 @@ object BleBridge {
|
||||
newState == BluetoothProfile.STATE_CONNECTED
|
||||
&& status == BluetoothGatt.GATT_SUCCESS -> {
|
||||
Log.d(TAG, "GATT connected to $address, discovering services")
|
||||
gatt.discoverServices()
|
||||
// Runs on the BLE handler thread; a denied
|
||||
// BLUETOOTH_CONNECT throws SecurityException here, which
|
||||
// would crash the process. Catch and fail the connect.
|
||||
try {
|
||||
gatt.discoverServices()
|
||||
} catch (e: SecurityException) {
|
||||
Log.e(TAG, "BLUETOOTH_CONNECT denied during discoverServices", e)
|
||||
readyDeferred.complete(false)
|
||||
} catch (e: Exception) {
|
||||
Log.w(TAG, "discoverServices failed: ${e.message}")
|
||||
readyDeferred.complete(false)
|
||||
}
|
||||
}
|
||||
newState == BluetoothProfile.STATE_DISCONNECTED -> {
|
||||
Log.w(TAG, "GATT disconnected from $address (status=$status)")
|
||||
|
||||
@@ -105,12 +105,48 @@ class CaptureService : Service() {
|
||||
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
|
||||
val useRoot = intent?.getBooleanExtra(EXTRA_USE_ROOT, false) ?: false
|
||||
|
||||
// CRITICAL: startForeground must be called IMMEDIATELY — before
|
||||
// any other work, especially before getMediaProjection(). The
|
||||
// service type must match the work; pass it explicitly via
|
||||
// ServiceCompat so we stay compatible back to API 24.
|
||||
// CRITICAL (Android 14+): for the MediaProjection path, validate the
|
||||
// projection token BEFORE promoting to a foreground service with the
|
||||
// mediaProjection FGS type. On service recreation (system redelivery
|
||||
// or a stale relaunch) the consent token is gone — promoting first and
|
||||
// then discovering the dead token causes a spurious foreground-service
|
||||
// start + immediate stop, which on strict OEMs flickers the
|
||||
// notification or trips a stopSelf loop. Bail out cleanly here, before
|
||||
// startForeground, when the MediaProjection consent data is missing.
|
||||
val localIp = NetworkUtils.getLocalIpAddress(this) ?: "—"
|
||||
val url = "http://$localIp:$SERVER_PORT"
|
||||
|
||||
val mediaProjectionResultData: Intent? =
|
||||
if (!useRoot) extractProjectionResultData(intent) else null
|
||||
if (!useRoot && (intent == null || mediaProjectionResultData == null)) {
|
||||
// MediaProjection mode can't recover from a redelivery —
|
||||
// the consent token in the original intent is single-use.
|
||||
//
|
||||
// We were launched via startForegroundService(), so the OS REQUIRES
|
||||
// a startForeground() within ~5s even on this immediate-stop path,
|
||||
// or it raises the fatal ForegroundServiceDidNotStartInTimeException.
|
||||
// Promote with a benign SPECIAL_USE type (NOT mediaProjection — we
|
||||
// have no valid consent token, and requesting that type without an
|
||||
// active projection is exactly what we're avoiding) just long enough
|
||||
// to satisfy the contract, then stop.
|
||||
Log.w(TAG, "MediaProjection start without a valid consent token — stopping")
|
||||
runCatching {
|
||||
val bailType = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE) {
|
||||
ServiceInfo.FOREGROUND_SERVICE_TYPE_SPECIAL_USE
|
||||
} else {
|
||||
0
|
||||
}
|
||||
ServiceCompat.startForeground(this, NOTIFICATION_ID, buildNotification(url), bailType)
|
||||
}.onFailure { Log.w(TAG, "Bail-path startForeground failed: ${it.message}") }
|
||||
stopSelf()
|
||||
return START_NOT_STICKY
|
||||
}
|
||||
|
||||
// startForeground must be called IMMEDIATELY after the token check —
|
||||
// before any heavier work like getMediaProjection(). The service type
|
||||
// must match the work; pass it explicitly via ServiceCompat so we stay
|
||||
// compatible back to API 24. The MEDIA_PROJECTION type is only used
|
||||
// here once resultData is confirmed non-null (checked above).
|
||||
try {
|
||||
val type = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE) {
|
||||
var t = if (useRoot) {
|
||||
@@ -152,20 +188,13 @@ class CaptureService : Service() {
|
||||
// otherwise `isRunning=true` sticks forever when startForeground throws.
|
||||
isRunning = true
|
||||
|
||||
if (intent == null && !useRoot) {
|
||||
// MediaProjection mode can't recover from a redelivery —
|
||||
// the consent token in the original intent is single-use.
|
||||
Log.w(TAG, "Service restarted without intent (MediaProjection mode) — stopping")
|
||||
isRunning = false
|
||||
stopSelf()
|
||||
return START_NOT_STICKY
|
||||
}
|
||||
|
||||
try {
|
||||
if (useRoot) {
|
||||
startRootCapture(url)
|
||||
} else {
|
||||
startMediaProjectionCapture(intent!!, url)
|
||||
// mediaProjectionResultData is guaranteed non-null here — the
|
||||
// token was validated before startForeground above.
|
||||
startMediaProjectionCapture(intent!!, mediaProjectionResultData!!, url)
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "Failed to start capture", e)
|
||||
@@ -294,20 +323,24 @@ class CaptureService : Service() {
|
||||
}
|
||||
}
|
||||
|
||||
private fun startMediaProjectionCapture(intent: Intent, url: String) {
|
||||
val resultCode = intent.getIntExtra(EXTRA_RESULT_CODE, 0)
|
||||
/**
|
||||
* Extract the single-use MediaProjection consent token from the start
|
||||
* intent, or null if the intent is missing/redelivered without it.
|
||||
* Called BEFORE startForeground so the mediaProjection FGS type is only
|
||||
* ever requested when a valid token is present (see onStartCommand).
|
||||
*/
|
||||
private fun extractProjectionResultData(intent: Intent?): Intent? {
|
||||
if (intent == null) return null
|
||||
@Suppress("DEPRECATION")
|
||||
val resultData = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
|
||||
return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
|
||||
intent.getParcelableExtra(EXTRA_RESULT_DATA, Intent::class.java)
|
||||
} else {
|
||||
intent.getParcelableExtra(EXTRA_RESULT_DATA)
|
||||
}
|
||||
}
|
||||
|
||||
if (resultData == null) {
|
||||
Log.e(TAG, "No MediaProjection result data")
|
||||
stopSelf()
|
||||
return
|
||||
}
|
||||
private fun startMediaProjectionCapture(intent: Intent, resultData: Intent, url: String) {
|
||||
val resultCode = intent.getIntExtra(EXTRA_RESULT_CODE, 0)
|
||||
|
||||
val projectionManager =
|
||||
getSystemService(Context.MEDIA_PROJECTION_SERVICE) as MediaProjectionManager
|
||||
|
||||
@@ -6,7 +6,9 @@ import android.service.notification.StatusBarNotification
|
||||
import android.util.Log
|
||||
import com.chaquo.python.Python
|
||||
import java.util.concurrent.ConcurrentHashMap
|
||||
import java.util.concurrent.ExecutorService
|
||||
import java.util.concurrent.Executors
|
||||
import java.util.concurrent.RejectedExecutionException
|
||||
|
||||
/**
|
||||
* Captures posted OS notifications and forwards the posting app's display
|
||||
@@ -25,7 +27,20 @@ class LedGrabNotificationListener : NotificationListenerService() {
|
||||
// Serial executor: the Python receiver does a (non-concurrency-safe) history
|
||||
// disk write and may play a sound, so pushes must not overlap. Off the main
|
||||
// looper to keep the system service responsive.
|
||||
private val pushExecutor = Executors.newSingleThreadExecutor()
|
||||
//
|
||||
// Tied to the listener-connection lifecycle (onListenerConnected /
|
||||
// onListenerDisconnected), NOT onDestroy: this is a system-rebindable
|
||||
// service, so it can be connected/disconnected multiple times across a
|
||||
// single onCreate..onDestroy span. Managing the executor here — combined
|
||||
// with the runCatching guard at the submit site — keeps a notification
|
||||
// that races teardown from triggering RejectedExecutionException on a
|
||||
// shut-down executor. @Volatile so the connect/disconnect callbacks (which
|
||||
// may run on a different thread than onNotificationPosted) publish safely.
|
||||
@Volatile private var pushExecutor: ExecutorService? = null
|
||||
|
||||
// Guards executor creation so the lazy submit-site fallback and
|
||||
// onListenerConnected can't race two executors into existence.
|
||||
private val executorLock = Any()
|
||||
|
||||
// packageName -> resolved human-readable label. Matches the app_name the
|
||||
// Windows/Linux backends pass, so per-app colors/filters keep working.
|
||||
@@ -51,17 +66,34 @@ class LedGrabNotificationListener : NotificationListenerService() {
|
||||
|
||||
val label = resolveAppLabel(notification.packageName)
|
||||
|
||||
pushExecutor.execute {
|
||||
try {
|
||||
Python.getInstance()
|
||||
.getModule(PY_MODULE)
|
||||
.callAttr("push_notification", label)
|
||||
} catch (t: Throwable) {
|
||||
// Never crash a system-bound service. Python.getInstance() throws
|
||||
// IllegalStateException if Python.start() hasn't run (e.g. the
|
||||
// service was bound at boot before the app process initialized).
|
||||
// Log at debug — the label is potentially sensitive on a shared TV.
|
||||
Log.d(TAG, "push_notification failed: ${t.message}")
|
||||
// Obtain (creating if needed) the executor. onListenerConnected normally
|
||||
// creates it, but that callback is not reliably invoked on every
|
||||
// OEM/version (re)bind, and a notification can arrive before it fires —
|
||||
// lazily creating here keeps a missing/late onListenerConnected from
|
||||
// permanently disabling notification forwarding. A late submit onto an
|
||||
// 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()
|
||||
runCatching {
|
||||
executor.execute {
|
||||
try {
|
||||
Python.getInstance()
|
||||
.getModule(PY_MODULE)
|
||||
.callAttr("push_notification", label)
|
||||
} catch (t: Throwable) {
|
||||
// Never crash a system-bound service. Python.getInstance() throws
|
||||
// IllegalStateException if Python.start() hasn't run (e.g. the
|
||||
// service was bound at boot before the app process initialized).
|
||||
// Log at debug — the label is potentially sensitive on a shared TV.
|
||||
Log.d(TAG, "push_notification failed: ${t.message}")
|
||||
}
|
||||
}
|
||||
}.onFailure { e ->
|
||||
if (e is RejectedExecutionException) {
|
||||
Log.d(TAG, "push rejected — listener disconnecting")
|
||||
} else {
|
||||
throw e
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -69,24 +101,59 @@ class LedGrabNotificationListener : NotificationListenerService() {
|
||||
/** Resolve (and cache) a package's human-readable label; fall back to the package name. */
|
||||
private fun resolveAppLabel(pkg: String): String {
|
||||
labelCache[pkg]?.let { return it }
|
||||
// Only cache SUCCESSFUL resolutions. Caching the package-name fallback
|
||||
// would permanently pin a wrong label if the PackageManager lookup
|
||||
// failed transiently (e.g. the app was mid-install / still updating).
|
||||
val resolved = runCatching {
|
||||
val info = packageManager.getApplicationInfo(pkg, 0)
|
||||
packageManager.getApplicationLabel(info).toString()
|
||||
}.getOrDefault(pkg)
|
||||
labelCache[pkg] = resolved
|
||||
return resolved
|
||||
}.getOrNull()
|
||||
if (resolved != null) {
|
||||
labelCache[pkg] = resolved
|
||||
return resolved
|
||||
}
|
||||
return pkg
|
||||
}
|
||||
|
||||
/**
|
||||
* 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.
|
||||
*/
|
||||
private fun ensureExecutor(): ExecutorService {
|
||||
pushExecutor?.let { return it }
|
||||
synchronized(executorLock) {
|
||||
return pushExecutor ?: Executors.newSingleThreadExecutor().also { pushExecutor = it }
|
||||
}
|
||||
}
|
||||
|
||||
override fun onListenerConnected() {
|
||||
Log.i(TAG, "Notification listener connected")
|
||||
// Spin up the push executor on connect. The system can disconnect and
|
||||
// later reconnect this service without destroying it, so own the
|
||||
// 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.
|
||||
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.
|
||||
pushExecutor?.let { exec ->
|
||||
pushExecutor = null
|
||||
exec.shutdown()
|
||||
}
|
||||
}
|
||||
|
||||
override fun onDestroy() {
|
||||
pushExecutor.shutdown()
|
||||
// Defensive: onListenerDisconnected normally clears this first, but
|
||||
// shut down here too in case onDestroy fires without a prior disconnect.
|
||||
pushExecutor?.shutdown()
|
||||
pushExecutor = null
|
||||
super.onDestroy()
|
||||
}
|
||||
|
||||
|
||||
@@ -34,6 +34,7 @@ import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.MainScope
|
||||
import kotlinx.coroutines.cancel
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.runInterruptible
|
||||
import kotlinx.coroutines.withContext
|
||||
|
||||
/**
|
||||
@@ -56,6 +57,7 @@ class MainActivity : Activity() {
|
||||
private const val REQUEST_POST_NOTIFICATIONS = 1002
|
||||
private const val REQUEST_RECORD_AUDIO = 1003
|
||||
private const val REQUEST_CAMERA = 1004
|
||||
private const val REQUEST_BLUETOOTH = 1005
|
||||
private const val QR_SIZE_PX = 560
|
||||
private const val NOTIF_PREFS = "ledgrab_notif"
|
||||
private const val KEY_NOTIF_ACCESS_PROMPTED = "notif_access_prompted"
|
||||
@@ -189,7 +191,13 @@ class MainActivity : Activity() {
|
||||
toggleButton.text = getString(R.string.btn_starting)
|
||||
statusText.text = getString(R.string.status_checking_root)
|
||||
uiScope.launch(Dispatchers.IO) {
|
||||
val rooted = Root.requestGrant()
|
||||
// runInterruptible so a config change (rotation) during the
|
||||
// up-to-10s `su` probe cancels the coroutine AND interrupts the
|
||||
// blocking probe thread — Root.requestGrant honours the interrupt,
|
||||
// destroys the su child, and rethrows, so we don't leak the
|
||||
// process + drain thread. Without this, IO-dispatcher cancellation
|
||||
// would not interrupt the blocking waitFor().
|
||||
val rooted = runInterruptible { Root.requestGrant() }
|
||||
withContext(Dispatchers.Main) {
|
||||
toggleButton.isEnabled = true
|
||||
toggleButton.text = originalText
|
||||
@@ -214,6 +222,7 @@ class MainActivity : Activity() {
|
||||
ensureNotificationPermission()
|
||||
ensureNotificationListenerAccess()
|
||||
ensureCameraPermission()
|
||||
ensureBluetoothPermissions()
|
||||
ContextCompat.startForegroundService(this, CaptureService.createRootIntent(this))
|
||||
updateUI()
|
||||
}
|
||||
@@ -236,6 +245,7 @@ class MainActivity : Activity() {
|
||||
ensureNotificationListenerAccess()
|
||||
ensureAudioPermission()
|
||||
ensureCameraPermission()
|
||||
ensureBluetoothPermissions()
|
||||
val intent = CaptureService.createIntent(this, resultCode, resultData)
|
||||
ContextCompat.startForegroundService(this, intent)
|
||||
updateUI()
|
||||
@@ -536,6 +546,30 @@ class MainActivity : Activity() {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Request BLUETOOTH_SCAN + BLUETOOTH_CONNECT (API 31+) so the embedded
|
||||
* server can discover and drive BLE LED controllers (SP110E / Triones /
|
||||
* Zengge). On API < 31 these are install-time legacy permissions
|
||||
* (BLUETOOTH / BLUETOOTH_ADMIN / ACCESS_FINE_LOCATION, maxSdk=30) and
|
||||
* need no runtime grant — so this is a no-op there. Fire-and-forget,
|
||||
* like [ensureAudioPermission]: screen capture works without BLE, and
|
||||
* BleBridge degrades gracefully (empty scan / failed connect) when the
|
||||
* grant is denied, so we don't block on the result. If first granted
|
||||
* here, BLE devices become reachable on the next scan/connect.
|
||||
*/
|
||||
private fun ensureBluetoothPermissions() {
|
||||
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.S) return
|
||||
val needed = listOf(
|
||||
Manifest.permission.BLUETOOTH_SCAN,
|
||||
Manifest.permission.BLUETOOTH_CONNECT,
|
||||
).filter {
|
||||
checkSelfPermission(it) != PackageManager.PERMISSION_GRANTED
|
||||
}
|
||||
if (needed.isEmpty()) return
|
||||
@Suppress("DEPRECATION")
|
||||
requestPermissions(needed.toTypedArray(), REQUEST_BLUETOOTH)
|
||||
}
|
||||
|
||||
/** Whether the user has granted notification-listener access to this app. */
|
||||
private fun isNotificationAccessGranted(): Boolean =
|
||||
NotificationManagerCompat.getEnabledListenerPackages(this).contains(packageName)
|
||||
|
||||
@@ -20,6 +20,11 @@ import java.util.concurrent.TimeUnit
|
||||
object Root {
|
||||
private const val TAG = "Root"
|
||||
|
||||
// Slice length for the cancellation-aware su probe wait loop. Short
|
||||
// enough that coroutine cancellation is honoured promptly, long enough
|
||||
// to avoid busy-spinning while Magisk's grant dialog is up.
|
||||
private const val POLL_SLICE_MS = 100L
|
||||
|
||||
private val SU_PATHS = listOf(
|
||||
"/system/bin/su",
|
||||
"/system/xbin/su",
|
||||
@@ -49,17 +54,19 @@ object Root {
|
||||
return false
|
||||
}
|
||||
|
||||
var process: Process? = null
|
||||
val granted = try {
|
||||
// redirectErrorStream merges stderr into stdout so a single
|
||||
// drain thread is enough — avoids the classic pipe-buffer
|
||||
// deadlock where waitFor() blocks because stderr filled up.
|
||||
val process = ProcessBuilder("su", "-c", "id")
|
||||
val proc = ProcessBuilder("su", "-c", "id")
|
||||
.redirectErrorStream(true)
|
||||
.start()
|
||||
process = proc
|
||||
val outputBuilder = StringBuilder()
|
||||
val drain = Thread({
|
||||
try {
|
||||
BufferedReader(InputStreamReader(process.inputStream)).use { r ->
|
||||
BufferedReader(InputStreamReader(proc.inputStream)).use { r ->
|
||||
val buf = CharArray(512)
|
||||
while (true) {
|
||||
val n = r.read(buf)
|
||||
@@ -72,17 +79,35 @@ object Root {
|
||||
}
|
||||
}, "Root-su-drain").apply { isDaemon = true; start() }
|
||||
|
||||
val finished = process.waitFor(timeoutSeconds, TimeUnit.SECONDS)
|
||||
// Cancellation-aware wait: callers run this on a coroutine
|
||||
// (MainActivity wraps it in runInterruptible), so a config change
|
||||
// mid-probe cancels the coroutine and interrupts this thread.
|
||||
// Poll waitFor() in short slices and honour interruption so we
|
||||
// don't leak the `su` child + its drain thread for up to 10s.
|
||||
// The catch(InterruptedException) below destroys the process; we
|
||||
// re-arm the interrupt and rethrow so coroutine cancellation
|
||||
// propagates cleanly.
|
||||
val deadlineNanos = System.nanoTime() + TimeUnit.SECONDS.toNanos(timeoutSeconds)
|
||||
var finished = false
|
||||
while (System.nanoTime() < deadlineNanos) {
|
||||
if (proc.waitFor(POLL_SLICE_MS, TimeUnit.MILLISECONDS)) {
|
||||
finished = true
|
||||
break
|
||||
}
|
||||
// Throws InterruptedException if the thread was interrupted
|
||||
// by coroutine cancellation — handled below to tear down.
|
||||
if (Thread.interrupted()) throw InterruptedException("su probe cancelled")
|
||||
}
|
||||
if (!finished) {
|
||||
process.destroyForcibly()
|
||||
proc.destroyForcibly()
|
||||
drain.join(500)
|
||||
Log.w(TAG, "su -c id timed out after ${timeoutSeconds}s")
|
||||
false
|
||||
} else {
|
||||
drain.join(500)
|
||||
val output = synchronized(outputBuilder) { outputBuilder.toString() }
|
||||
if (process.exitValue() != 0) {
|
||||
Log.w(TAG, "su -c id exited with ${process.exitValue()} output='${output.trim()}'")
|
||||
if (proc.exitValue() != 0) {
|
||||
Log.w(TAG, "su -c id exited with ${proc.exitValue()} output='${output.trim()}'")
|
||||
false
|
||||
} else {
|
||||
val rooted = output.contains("uid=0")
|
||||
@@ -90,8 +115,17 @@ object Root {
|
||||
rooted
|
||||
}
|
||||
}
|
||||
} catch (e: InterruptedException) {
|
||||
// Coroutine cancelled mid-probe (e.g. config change). Kill the
|
||||
// su child so it doesn't outlive the cancelled work, re-arm the
|
||||
// interrupt flag, and rethrow so the coroutine cancels cleanly.
|
||||
// Do NOT cache a result — the probe never completed.
|
||||
runCatching { process?.destroyForcibly() }
|
||||
Thread.currentThread().interrupt()
|
||||
throw e
|
||||
} catch (e: Exception) {
|
||||
Log.w(TAG, "su invocation failed: ${e.message}")
|
||||
runCatching { process?.destroyForcibly() }
|
||||
false
|
||||
}
|
||||
|
||||
|
||||
@@ -89,14 +89,15 @@ class RootScreenrecord(
|
||||
running = true
|
||||
|
||||
try {
|
||||
imageReader = buildImageReader()
|
||||
decoder = buildDecoder(imageReader!!)
|
||||
process = spawnScreenrecord() ?: run {
|
||||
val reader = buildImageReader().also { imageReader = it }
|
||||
val codec = buildDecoder(reader).also { decoder = it }
|
||||
val proc = spawnScreenrecord() ?: run {
|
||||
stop()
|
||||
return false
|
||||
}
|
||||
startInputPump(process!!.inputStream, decoder!!)
|
||||
startOutputDrain(decoder!!)
|
||||
process = proc
|
||||
startInputPump(proc.inputStream, codec)
|
||||
startOutputDrain(codec)
|
||||
Log.i(TAG, "Root capture pipeline started (${width}x$height @ ${fps}fps)")
|
||||
return true
|
||||
} catch (e: Exception) {
|
||||
@@ -178,6 +179,14 @@ class RootScreenrecord(
|
||||
buffer.get(frameBuffer, row * rowBytes, rowBytes)
|
||||
}
|
||||
}
|
||||
// CONTRACT: frameBuffer is REUSED across frames (single-threaded
|
||||
// reader callback — no copy here). Safety depends on the Python
|
||||
// receiver copying the bytes before this callback returns and
|
||||
// overwrites the buffer for the next frame. It does:
|
||||
// PythonBridge.pushRootFrame → root_screenrecord_engine.push_frame
|
||||
// (server/src/ledgrab/core/capture_engines/root_screenrecord_engine.py)
|
||||
// does `rgba[:, :, :3].copy()`, so the queued frame owns its
|
||||
// pixels independently of this buffer. Do NOT remove that copy.
|
||||
bridge.pushRootFrame(frameBuffer, width, height)
|
||||
framesDeliveredCounter.incrementAndGet()
|
||||
} catch (e: Exception) {
|
||||
|
||||
@@ -147,6 +147,14 @@ class ScreenCapture(
|
||||
}
|
||||
}
|
||||
|
||||
// CONTRACT: frameBuffer is REUSED across frames (single-threaded
|
||||
// capture handler — no copy here). Safety depends on the Python
|
||||
// receiver copying the bytes before this callback returns and
|
||||
// overwrites the buffer for the next frame. It does:
|
||||
// PythonBridge.pushFrame → mediaprojection_engine.push_frame
|
||||
// (server/src/ledgrab/core/capture_engines/mediaprojection_engine.py)
|
||||
// does `rgba[:, :, :3].copy()`, so the queued frame owns its
|
||||
// pixels independently of this buffer. Do NOT remove that copy.
|
||||
bridge.pushFrame(frameBuffer, captureWidth, captureHeight)
|
||||
|
||||
// Advance the pacing accumulator. If we fell badly behind
|
||||
|
||||
Reference in New Issue
Block a user