fix: comprehensive security, stability, and code quality audit
Build Android APK / build-android (push) Failing after 1m45s
Lint & Test / test (push) Successful in 4m54s

Security:
- Force API key auth for LAN (non-loopback) requests; remove shipped dev key
- Block path-traversal in backup restore; require auth on backup endpoints
- SSRF protection: DNS resolve + private/loopback/link-local IP rejection
- AES-256-GCM encryption for HA tokens and MQTT passwords with auto-migration
- WebSocket auth migrated from query-string to first-message protocol
- Asset upload: extension allowlist, server-side mime, Content-Disposition
- Update installer: SHA256 verification, tar/zip member validation
- Tightened CORS (explicit methods/headers, no credentials)
- ADB serial regex allowlist, webhook rate-limit key fix, log scrubbing

Android:
- Root-capture: ordered teardown, screenrecord respawn watchdog, child reaping
- USB permission blocking API via CompletableDeferred
- Python init crash guard with fatal-error screen
- Moved root grant + QR generation off Main thread
- Cached PyObject engine for per-frame bridge calls
- Ordered ScreenCapture resource cleanup, allowBackup=false

Python:
- Replaced all asyncio.get_event_loop() with get_running_loop/to_thread
- Split color_strip_sources.py (1683->5 files) and color_strip_stream.py
  (1324->7 files) into packages
- Extracted FrameLimiter utility, migrated 9 stream loops
- Provider base-class reuse, WLED state caching + URL normalization
- Narrowed broad except-pass in WS routes, threading fixes in BaseStore

Frontend:
- XSS fix: escapeHtml on dynamic option labels, reconcile-based list renders
- Typed DOM helpers, safe localStorage access, AbortController listener hygiene
- openAuthedWs helper for first-message WS auth protocol
- Migrated remaining plain <select>s to IconSelect/EntitySelect

Design:
- WCAG AA primary color on light theme (#2e7d32, 5.4:1 contrast)
- Android TV 10-foot breakpoint (tv.css)
- Consolidated z-index tokens, unified easing, card-running GPU hints
This commit is contained in:
2026-04-16 04:56:04 +03:00
parent 5fcb9f82bd
commit 123da1b5c4
124 changed files with 6276 additions and 3705 deletions
+15
View File
@@ -45,7 +45,19 @@ android {
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 {
@@ -97,6 +109,7 @@ chaquopy {
// and falls back to numpy/Pillow alternatives on Android.
install("Pillow")
install("websockets")
install("cryptography") // AES-GCM secret-box for HA/MQTT credentials
}
}
}
@@ -114,6 +127,8 @@ dependencies {
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
+27
View File
@@ -0,0 +1,27 @@
# LedGrab ProGuard / R8 rules.
#
# IMPORTANT: Chaquopy resolves Java/Kotlin classes and static methods by
# name from Python (e.g. UsbSerialBridge.INSTANCE.listDevices()) via
# reflection. Anything reachable through PyObject must be kept by name
# or the release build will throw NoSuchMethod / ClassNotFound at
# runtime silently, only on the user's device.
#
# Keep ALL of com.ledgrab.android.* members for safety. The app is
# small enough that the size win from stripping these isn't worth the
# fragility.
-keep class com.ledgrab.android.** { *; }
# Chaquopy runtime itself.
-keep class com.chaquo.python.** { *; }
-dontwarn com.chaquo.python.**
# usb-serial-for-android driver classes are loaded via the prober's
# default device-id list, which uses reflection in some chip drivers.
-keep class com.hoho.android.usbserial.driver.** { *; }
-dontwarn com.hoho.android.usbserial.**
# Kotlin coroutines keep the debug agent off and the metadata intact.
-dontwarn kotlinx.coroutines.**
# Standard Android best-practice keeps.
-keepattributes Signature, InnerClasses, EnclosingMethod, *Annotation*
+1 -1
View File
@@ -29,7 +29,7 @@
<application
android:name=".LedGrabApp"
android:allowBackup="true"
android:allowBackup="false"
android:icon="@drawable/ic_launcher"
android:label="@string/app_name"
android:banner="@drawable/ic_launcher"
@@ -93,13 +93,14 @@ class CaptureService : Service() {
}
private fun startRootCapture(url: String) {
bridge = PythonBridge(this).also { b ->
val newBridge = PythonBridge(this).also { b ->
b.configureRootCapture(CAPTURE_WIDTH, CAPTURE_HEIGHT)
b.startServer(SERVER_PORT)
}
bridge = newBridge
val pipeline = RootScreenrecord(
bridge = bridge!!,
bridge = newBridge,
width = CAPTURE_WIDTH,
height = CAPTURE_HEIGHT,
fps = CAPTURE_FPS,
@@ -130,28 +131,29 @@ class CaptureService : Service() {
val projectionManager =
getSystemService(Context.MEDIA_PROJECTION_SERVICE) as MediaProjectionManager
mediaProjection = projectionManager.getMediaProjection(resultCode, resultData)
if (mediaProjection == null) {
val projection = projectionManager.getMediaProjection(resultCode, resultData)
if (projection == null) {
Log.e(TAG, "Failed to create MediaProjection")
stopSelf()
return
}
mediaProjection = projection
val windowManager = getSystemService(Context.WINDOW_SERVICE) as WindowManager
val metrics = DisplayMetrics()
@Suppress("DEPRECATION")
windowManager.defaultDisplay.getRealMetrics(metrics)
bridge = PythonBridge(this).also { b ->
val newBridge = PythonBridge(this).also { b ->
b.configureCapture(CAPTURE_WIDTH, CAPTURE_HEIGHT)
b.startServer(SERVER_PORT)
}
bridge = newBridge
screenCapture = ScreenCapture(
projection = mediaProjection!!,
projection = projection,
metrics = metrics,
bridge = bridge!!,
bridge = newBridge,
targetWidth = CAPTURE_WIDTH,
targetHeight = CAPTURE_HEIGHT,
targetFps = CAPTURE_FPS,
@@ -1,6 +1,7 @@
package com.ledgrab.android
import android.app.Application
import android.util.Log
import com.chaquo.python.Python
import com.chaquo.python.android.AndroidPlatform
@@ -13,10 +14,23 @@ import com.chaquo.python.android.AndroidPlatform
*/
class LedGrabApp : Application() {
/** Set if [Python.start] threw — surfaced by MainActivity. */
@Volatile
var initError: Throwable? = null
private set
override fun onCreate() {
super.onCreate()
if (!Python.isStarted()) {
Python.start(AndroidPlatform(this))
try {
if (!Python.isStarted()) {
Python.start(AndroidPlatform(this))
}
} catch (t: Throwable) {
// Don't crash here — MainActivity will render a failure
// screen with a Copy log button so the user can report it.
Log.e("LedGrabApp", "Python.start() failed", t)
initError = t
return
}
// Bind application context for the USB-serial bridge so Python
// can enumerate and open USB-to-TTL adapters without needing
@@ -1,6 +1,5 @@
package com.ledgrab.android
import android.app.Activity
import android.content.Intent
import android.graphics.Bitmap
import android.media.projection.MediaProjectionManager
@@ -10,8 +9,15 @@ import android.view.View
import android.widget.Button
import android.widget.ImageView
import android.widget.TextView
import android.app.Activity
import com.google.zxing.BarcodeFormat
import com.google.zxing.qrcode.QRCodeWriter
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.MainScope
import kotlinx.coroutines.cancel
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
/**
* Main (and only) Activity for the Android TV app.
@@ -21,6 +27,11 @@ import com.google.zxing.qrcode.QRCodeWriter
*/
class MainActivity : Activity() {
// Activity-scoped coroutine scope. We don't depend on AppCompat /
// androidx.lifecycle's lifecycleScope here because the TV launcher
// theme inherits from Leanback (non-AppCompat).
private val uiScope: CoroutineScope = MainScope()
companion object {
private const val TAG = "MainActivity"
private const val SERVER_PORT = 8080
@@ -39,6 +50,14 @@ class MainActivity : Activity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
// Surface fatal Python init errors instead of crashing.
val initError = (application as? LedGrabApp)?.initError
if (initError != null) {
showFatalErrorScreen(initError)
return
}
setContentView(R.layout.activity_main)
stoppedPanel = findViewById(R.id.stopped_panel)
@@ -52,7 +71,7 @@ class MainActivity : Activity() {
val versionName = packageManager
.getPackageInfo(packageName, 0).versionName
versionText.text = "v$versionName"
versionText.text = "v${versionName ?: "?"}"
toggleButton.setOnClickListener { startCapture() }
stopButtonRunning.setOnClickListener { stopCaptureService() }
@@ -67,12 +86,28 @@ class MainActivity : Activity() {
* on the UI thread is acceptable because we're responding to a
* button press and we want to block until the user answers.
*/
override fun onDestroy() {
uiScope.cancel()
super.onDestroy()
}
private fun startCapture() {
if (Root.requestGrant()) {
Log.i(TAG, "Root available — skipping MediaProjection consent")
startRootCaptureService()
} else {
requestMediaProjection()
// `su -c id` can block for seconds while Magisk shows its grant
// dialog; running it on the Main thread caused ANRs.
toggleButton.isEnabled = false
statusText.text = "Checking root access…"
uiScope.launch(Dispatchers.IO) {
val rooted = Root.requestGrant()
withContext(Dispatchers.Main) {
toggleButton.isEnabled = true
statusText.text = ""
if (rooted) {
Log.i(TAG, "Root available — skipping MediaProjection consent")
startRootCaptureService()
} else {
requestMediaProjection()
}
}
}
}
@@ -120,7 +155,17 @@ class MainActivity : Activity() {
val url = "http://$localIp:$SERVER_PORT"
urlText.text = url
qrImage.setImageBitmap(generateQrCode(url))
qrImage.setImageBitmap(null)
// Build the bitmap pixels off the Main thread — encode + 313k
// setPixel calls were noticeably janky on slow TV boxes.
uiScope.launch(Dispatchers.Default) {
val bitmap = generateQrCode(url)
withContext(Dispatchers.Main) {
if (serviceRunning && urlText.text == url) {
qrImage.setImageBitmap(bitmap)
}
}
}
stoppedPanel.visibility = View.GONE
versionText.visibility = View.GONE
@@ -140,12 +185,54 @@ class MainActivity : Activity() {
private fun generateQrCode(text: String): Bitmap {
val size = 560
val bitMatrix = QRCodeWriter().encode(text, BarcodeFormat.QR_CODE, size, size)
val bitmap = Bitmap.createBitmap(size, size, Bitmap.Config.RGB_565)
for (x in 0 until size) {
for (y in 0 until size) {
bitmap.setPixel(x, y, if (bitMatrix[x, y]) 0xFF000000.toInt() else 0xFFFFFFFF.toInt())
val pixels = IntArray(size * size)
for (y in 0 until size) {
val rowOffset = y * size
for (x in 0 until size) {
pixels[rowOffset + x] =
if (bitMatrix[x, y]) 0xFF000000.toInt() else 0xFFFFFFFF.toInt()
}
}
val bitmap = Bitmap.createBitmap(size, size, Bitmap.Config.RGB_565)
bitmap.setPixels(pixels, 0, size, 0, 0, size, size)
return bitmap
}
/**
* Minimal failure UI shown when Python.start() (Chaquopy) blew up.
* Rendered programmatically so we don't depend on the regular layout
* (which itself may reference resources affected by the failure).
*/
private fun showFatalErrorScreen(error: Throwable) {
Log.e(TAG, "Fatal init error — showing error screen", error)
val stackText = android.util.Log.getStackTraceString(error)
val container = android.widget.LinearLayout(this).apply {
orientation = android.widget.LinearLayout.VERTICAL
setPadding(48, 48, 48, 48)
}
val title = TextView(this).apply {
text = "LedGrab failed to start"
textSize = 22f
}
val body = TextView(this).apply {
text = "Python runtime initialization failed:\n\n$stackText"
textSize = 12f
setTextIsSelectable(true)
}
val copyBtn = Button(this).apply {
text = "Copy log"
setOnClickListener {
val cm = getSystemService(CLIPBOARD_SERVICE)
as android.content.ClipboardManager
cm.setPrimaryClip(
android.content.ClipData.newPlainText("LedGrab error", stackText)
)
}
}
val scroll = android.widget.ScrollView(this).apply { addView(body) }
container.addView(title)
container.addView(copyBtn)
container.addView(scroll)
setContentView(container)
}
}
@@ -2,6 +2,7 @@ package com.ledgrab.android
import android.content.Context
import android.util.Log
import com.chaquo.python.PyObject
import com.chaquo.python.Python
/**
@@ -19,6 +20,14 @@ class PythonBridge(private val context: Context) {
private var serverThread: Thread? = null
@Volatile private var running = false
// Cached PyObject handles for the per-frame fast path. Looking these
// up via Python.getInstance().getModule(...) every frame was a real
// measurable cost (~1ms/frame on TV boxes). Cached once at configure
// time and read on the capture thread — @Volatile is enough for the
// single-writer/single-reader pattern we have here.
@Volatile private var mediaProjectionEngine: PyObject? = null
@Volatile private var rootEngine: PyObject? = null
/**
* Configure the MediaProjection engine with screen dimensions.
* Must be called before [startServer].
@@ -27,6 +36,7 @@ class PythonBridge(private val context: Context) {
val py = Python.getInstance()
val engine = py.getModule("ledgrab.core.capture_engines.mediaprojection_engine")
engine.callAttr("configure", width, height)
mediaProjectionEngine = engine
Log.i(TAG, "MediaProjection engine configured: ${width}x${height}")
}
@@ -38,6 +48,7 @@ class PythonBridge(private val context: Context) {
val py = Python.getInstance()
val engine = py.getModule("ledgrab.core.capture_engines.root_screenrecord_engine")
engine.callAttr("configure", width, height)
rootEngine = engine
Log.i(TAG, "Root screenrecord engine configured: ${width}x${height}")
}
@@ -99,10 +110,9 @@ class PythonBridge(private val context: Context) {
*/
fun pushFrame(rgbaBytes: ByteArray, width: Int, height: Int) {
if (!running) return
val engine = mediaProjectionEngine ?: return
try {
val py = Python.getInstance()
val engine = py.getModule("ledgrab.core.capture_engines.mediaprojection_engine")
engine.callAttr("push_frame", rgbaBytes, width, height)
} catch (e: Exception) {
Log.w(TAG, "Failed to push frame: ${e.message}")
@@ -118,10 +128,9 @@ class PythonBridge(private val context: Context) {
*/
fun pushRootFrame(rgbaBytes: ByteArray, width: Int, height: Int) {
if (!running) return
val engine = rootEngine ?: return
try {
val py = Python.getInstance()
val engine = py.getModule("ledgrab.core.capture_engines.root_screenrecord_engine")
engine.callAttr("push_frame", rgbaBytes, width, height)
} catch (e: Exception) {
Log.w(TAG, "Failed to push root frame: ${e.message}")
@@ -50,20 +50,45 @@ object Root {
}
val granted = try {
val process = Runtime.getRuntime().exec(arrayOf("su", "-c", "id"))
// 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")
.redirectErrorStream(true)
.start()
val outputBuilder = StringBuilder()
val drain = Thread({
try {
BufferedReader(InputStreamReader(process.inputStream)).use { r ->
val buf = CharArray(512)
while (true) {
val n = r.read(buf)
if (n < 0) break
synchronized(outputBuilder) { outputBuilder.append(buf, 0, n) }
}
}
} catch (_: Exception) {
// Process gone — drain ends.
}
}, "Root-su-drain").apply { isDaemon = true; start() }
val finished = process.waitFor(timeoutSeconds, TimeUnit.SECONDS)
if (!finished) {
process.destroyForcibly()
drain.join(500)
Log.w(TAG, "su -c id timed out after ${timeoutSeconds}s")
false
} else if (process.exitValue() != 0) {
Log.w(TAG, "su -c id exited with ${process.exitValue()}")
false
} else {
val output = BufferedReader(InputStreamReader(process.inputStream)).readText()
val rooted = output.contains("uid=0")
Log.i(TAG, "su -c id → '${output.trim()}' → rooted=$rooted")
rooted
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()}'")
false
} else {
val rooted = output.contains("uid=0")
Log.i(TAG, "su -c id → '${output.trim()}' → rooted=$rooted")
rooted
}
}
} catch (e: Exception) {
Log.w(TAG, "su invocation failed: ${e.message}")
@@ -74,6 +99,35 @@ object Root {
return granted
}
/**
* Run an `su -c <cmd>` command. Returns true on exit-zero. Failure
* invalidates the cached grant so the next [requestGrant] re-checks
* (covers cases like Magisk grant being revoked mid-session).
*/
@JvmStatic
fun runAsRoot(cmd: String, timeoutSeconds: Long = 5): Boolean {
return try {
val process = ProcessBuilder("su", "-c", cmd)
.redirectErrorStream(true)
.start()
val finished = process.waitFor(timeoutSeconds, TimeUnit.SECONDS)
if (!finished) {
process.destroyForcibly()
cachedGranted = null
false
} else if (process.exitValue() != 0) {
cachedGranted = null
false
} else {
true
}
} catch (e: Exception) {
Log.w(TAG, "runAsRoot('$cmd') failed: ${e.message}")
cachedGranted = null
false
}
}
/** Forget the cached grant result — useful if Magisk permission was revoked. */
@JvmStatic
fun invalidateCache() {
@@ -39,7 +39,7 @@ class RootScreenrecord(
private const val INPUT_CHUNK = 64 * 1024
}
private var process: Process? = null
@Volatile private var process: Process? = null
private var decoder: MediaCodec? = null
private var imageReader: ImageReader? = null
private var readerThread: HandlerThread? = null
@@ -47,6 +47,7 @@ class RootScreenrecord(
private var outputThread: Thread? = null
@Volatile private var running = false
@Volatile private var framesDelivered = 0
@Volatile private var stopped = false
/** True once at least one frame has reached the Python bridge. */
val hasProducedFrame: Boolean get() = framesDelivered > 0
@@ -80,27 +81,44 @@ class RootScreenrecord(
}
/** Stop everything and release resources. Idempotent. */
@Synchronized
fun stop() {
if (stopped) return
stopped = true
// Order matters: signal first so worker loops drop out, then
// stop the codec on the thread that created it (this one), then
// join workers BEFORE releasing the codec/ImageReader they may
// still be touching, then kill the external screenrecord process.
running = false
runCatching { decoder?.stop() }
inputThread?.interrupt()
outputThread?.interrupt()
runCatching { inputThread?.join(500) }
runCatching { outputThread?.join(500) }
inputThread = null
outputThread = null
runCatching { process?.destroy() }
process = null
// Best-effort: kill the screenrecord child before reaping `su`,
// otherwise screenrecord can outlive su as an orphan and keep
// the GPU encoder busy. Fire-and-forget; ignore failures.
runCatching { Root.runAsRoot("pkill -TERM screenrecord", timeoutSeconds = 2) }
runCatching { decoder?.stop() }
runCatching { decoder?.release() }
decoder = null
runCatching { imageReader?.setOnImageAvailableListener(null, null) }
runCatching { imageReader?.close() }
imageReader = null
readerThread?.quitSafely()
runCatching { readerThread?.join(500) }
readerThread = null
runCatching { process?.destroy() }
process = null
Log.i(TAG, "Root capture pipeline stopped (frames delivered: $framesDelivered)")
}
@@ -169,15 +187,35 @@ class RootScreenrecord(
}
}
private fun startInputPump(stream: InputStream, codec: MediaCodec) {
private fun startInputPump(initialStream: InputStream, codec: MediaCodec) {
inputThread = Thread({
val buf = ByteArray(INPUT_CHUNK)
var stream: InputStream = initialStream
try {
while (running) {
val n = stream.read(buf)
outer@ while (running) {
val n = try {
stream.read(buf)
} catch (e: Exception) {
if (!running) break
Log.w(TAG, "screenrecord read error: ${e.message}")
-1
}
if (n <= 0) {
Log.w(TAG, "screenrecord stdout closed (EOF)")
break
if (!running) break
// screenrecord caps at --time-limit=180s. When it
// exits cleanly we respawn so capture survives
// long sessions instead of freezing after ~3min.
Log.i(TAG, "screenrecord EOF — respawning")
runCatching { process?.destroy() }
val next = spawnScreenrecord()
if (next == null) {
// Avoid a tight loop if `su` is suddenly unhappy.
try { Thread.sleep(500) } catch (_: InterruptedException) { break }
continue@outer
}
process = next
stream = next.inputStream
continue@outer
}
var offset = 0
while (offset < n && running) {
@@ -128,13 +128,22 @@ class ScreenCapture(
*/
fun stop() {
running = false
virtualDisplay?.release()
// Order matters: detach the listener BEFORE releasing the
// VirtualDisplay so the handler can't be re-entered with stale
// resources, then quit & join the handler thread, only then
// close the ImageReader.
runCatching { imageReader?.setOnImageAvailableListener(null, null) }
runCatching { virtualDisplay?.release() }
virtualDisplay = null
imageReader?.close()
imageReader = null
captureThread?.quitSafely()
runCatching { captureThread?.join(500) }
captureThread = null
captureHandler = null
runCatching { imageReader?.close() }
imageReader = null
Log.i(TAG, "Screen capture stopped")
}
}
@@ -11,7 +11,13 @@ import android.util.Log
import com.hoho.android.usbserial.driver.UsbSerialDriver
import com.hoho.android.usbserial.driver.UsbSerialPort
import com.hoho.android.usbserial.driver.UsbSerialProber
import java.util.concurrent.ConcurrentHashMap
import java.util.concurrent.atomic.AtomicBoolean
import java.util.concurrent.atomic.AtomicInteger
import kotlinx.coroutines.CompletableDeferred
import kotlinx.coroutines.runBlocking
import kotlinx.coroutines.withTimeout
import kotlinx.coroutines.TimeoutCancellationException
/**
* USB-serial bridge exposed to the Python server via Chaquopy.
@@ -35,22 +41,32 @@ object UsbSerialBridge {
private val handleSeq = AtomicInteger(1)
private val openPorts = HashMap<Int, UsbSerialPort>()
private val initialized = AtomicBoolean(false)
private val pendingPermissions = ConcurrentHashMap<String, CompletableDeferred<Boolean>>()
/** Called once from [LedGrabApp.onCreate] so we can resolve services. */
@JvmStatic
fun init(context: Context) {
val app = context.applicationContext
appContext = app
// Idempotent: re-entrant init() must not double-register the
// receiver (which would leak listeners and double-fire callbacks).
if (!initialized.compareAndSet(false, true)) return
val filter = IntentFilter(ACTION_USB_PERMISSION)
val receiver = object : BroadcastReceiver() {
override fun onReceive(ctx: Context, intent: Intent) {
// We just log; the next open() call checks hasPermission() again.
val granted = intent.getBooleanExtra(
UsbManager.EXTRA_PERMISSION_GRANTED,
false,
)
Log.i(TAG, "USB permission broadcast: granted=$granted")
val device = intent.getParcelableExtra<android.hardware.usb.UsbDevice>(
UsbManager.EXTRA_DEVICE,
)
Log.i(TAG, "USB permission broadcast: granted=$granted device=${device?.deviceName}")
device?.deviceName?.let { name ->
pendingPermissions.remove(name)?.complete(granted)
}
}
}
// Android 14 requires RECEIVER_NOT_EXPORTED for non-system broadcasts.
@@ -183,6 +199,75 @@ object UsbSerialBridge {
.onFailure { Log.w(TAG, "close($handle): ${it.message}") }
}
/**
* Block until the user grants (or denies) USB permission for the
* device with [deviceName] (e.g. "/dev/bus/usb/001/004"). Returns
* true if granted within [timeoutMs], false otherwise. Safe to call
* from a Python thread via Chaquopy.
*/
@JvmStatic
@JvmOverloads
fun requestPermissionBlocking(deviceName: String, timeoutMs: Long = 15_000L): Boolean {
val context = ctx()
val manager = context.getSystemService(Context.USB_SERVICE) as UsbManager
val driver = UsbSerialProber.getDefaultProber().findAllDrivers(manager)
.firstOrNull { it.device.deviceName == deviceName }
?: return false
if (manager.hasPermission(driver.device)) return true
// Coalesce concurrent requests for the same device — only the
// first caller actually fires the system dialog.
val deferred = pendingPermissions.computeIfAbsent(deviceName) {
CompletableDeferred<Boolean>().also {
requestPermission(context, manager, driver)
}
}
return try {
runBlocking {
withTimeout(timeoutMs) { deferred.await() }
}
} catch (_: TimeoutCancellationException) {
pendingPermissions.remove(deviceName)
Log.w(TAG, "Permission request timed out for $deviceName")
false
} catch (e: Exception) {
pendingPermissions.remove(deviceName)
Log.w(TAG, "Permission request failed for $deviceName: ${e.message}")
false
}
}
/**
* Like [open] but blocks for permission first. Use this from Python
* instead of relying on the open()/retry pattern.
*/
@JvmStatic
@JvmOverloads
fun openWithPermission(
vendorId: Int,
productId: Int,
serial: String,
baud: Int,
timeoutMs: Long = 15_000L,
): Int {
val context = ctx()
val manager = context.getSystemService(Context.USB_SERVICE) as UsbManager
val driver = UsbSerialProber.getDefaultProber().findAllDrivers(manager)
.firstOrNull { d ->
val dev = d.device
dev.vendorId == vendorId &&
dev.productId == productId &&
(serial.isEmpty() || safeSerial(d) == serial)
} ?: return -1
if (!manager.hasPermission(driver.device)) {
val granted = requestPermissionBlocking(driver.device.deviceName, timeoutMs)
if (!granted) return -1
}
return open(vendorId, productId, serial, baud)
}
private fun requestPermission(
context: Context,
manager: UsbManager,