feat(android): root-based screen capture bypassing MediaProjection
Build Android APK / build-android (push) Failing after 1m40s
Lint & Test / test (push) Successful in 3m40s

On rooted TV boxes, spawn `su -c screenrecord ... -` and feed the
H.264 stdout through MediaCodec into an ImageReader, surfacing RGBA
frames via PythonBridge. RootScreenrecordEngine (priority 110) is
picked automatically when root is available; falls back to
MediaProjection when Root.requestGrant() returns false.
This commit is contained in:
2026-04-14 19:30:26 +03:00
parent 928d626620
commit 5fcb9f82bd
7 changed files with 631 additions and 39 deletions
@@ -27,7 +27,11 @@ class CaptureService : Service() {
private const val NOTIFICATION_ID = 1
private const val EXTRA_RESULT_CODE = "result_code"
private const val EXTRA_RESULT_DATA = "result_data"
private const val EXTRA_USE_ROOT = "use_root"
private const val SERVER_PORT = 8080
private const val CAPTURE_WIDTH = 480
private const val CAPTURE_HEIGHT = 270
private const val CAPTURE_FPS = 30
fun createIntent(
context: Context,
@@ -39,10 +43,18 @@ class CaptureService : Service() {
putExtra(EXTRA_RESULT_DATA, resultData)
}
}
/** Root-mode intent — no MediaProjection consent data required. */
fun createRootIntent(context: Context): Intent {
return Intent(context, CaptureService::class.java).apply {
putExtra(EXTRA_USE_ROOT, true)
}
}
}
private var bridge: PythonBridge? = null
private var screenCapture: ScreenCapture? = null
private var rootCapture: RootScreenrecord? = null
private var mediaProjection: MediaProjection? = null
override fun onBind(intent: Intent?): IBinder? = null
@@ -65,6 +77,43 @@ class CaptureService : Service() {
return START_NOT_STICKY
}
val useRoot = intent.getBooleanExtra(EXTRA_USE_ROOT, false)
try {
if (useRoot) {
startRootCapture(url)
} else {
startMediaProjectionCapture(intent, url)
}
} catch (e: Exception) {
Log.e(TAG, "Failed to start capture", e)
stopSelf()
}
return START_NOT_STICKY
}
private fun startRootCapture(url: String) {
bridge = PythonBridge(this).also { b ->
b.configureRootCapture(CAPTURE_WIDTH, CAPTURE_HEIGHT)
b.startServer(SERVER_PORT)
}
val pipeline = RootScreenrecord(
bridge = bridge!!,
width = CAPTURE_WIDTH,
height = CAPTURE_HEIGHT,
fps = CAPTURE_FPS,
)
if (!pipeline.start()) {
Log.w(TAG, "Root capture failed to launch — stopping service")
stopSelf()
return
}
rootCapture = pipeline
Log.i(TAG, "LedGrab service started (root mode) — web UI at $url")
}
private fun startMediaProjectionCapture(intent: Intent, url: String) {
val resultCode = intent.getIntExtra(EXTRA_RESULT_CODE, 0)
@Suppress("DEPRECATION")
val resultData = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
@@ -76,56 +125,48 @@ class CaptureService : Service() {
if (resultData == null) {
Log.e(TAG, "No MediaProjection result data")
stopSelf()
return START_NOT_STICKY
return
}
try {
// Create MediaProjection from the consent token
val projectionManager =
getSystemService(Context.MEDIA_PROJECTION_SERVICE) as MediaProjectionManager
mediaProjection = projectionManager.getMediaProjection(resultCode, resultData)
val projectionManager =
getSystemService(Context.MEDIA_PROJECTION_SERVICE) as MediaProjectionManager
mediaProjection = projectionManager.getMediaProjection(resultCode, resultData)
if (mediaProjection == null) {
Log.e(TAG, "Failed to create MediaProjection")
stopSelf()
return START_NOT_STICKY
}
// Get screen metrics
val windowManager = getSystemService(Context.WINDOW_SERVICE) as WindowManager
val metrics = DisplayMetrics()
@Suppress("DEPRECATION")
windowManager.defaultDisplay.getRealMetrics(metrics)
// Initialize Python bridge and configure capture engine
bridge = PythonBridge(this).also { b ->
b.configureCapture(480, 270) // Downscaled for LED use
b.startServer(SERVER_PORT)
}
// Start screen capture
screenCapture = ScreenCapture(
projection = mediaProjection!!,
metrics = metrics,
bridge = bridge!!,
targetWidth = 480,
targetHeight = 270,
targetFps = 30,
).also { it.start() }
Log.i(TAG, "LedGrab service started — web UI at $url")
} catch (e: Exception) {
Log.e(TAG, "Failed to start capture", e)
if (mediaProjection == null) {
Log.e(TAG, "Failed to create MediaProjection")
stopSelf()
return
}
return START_NOT_STICKY
val windowManager = getSystemService(Context.WINDOW_SERVICE) as WindowManager
val metrics = DisplayMetrics()
@Suppress("DEPRECATION")
windowManager.defaultDisplay.getRealMetrics(metrics)
bridge = PythonBridge(this).also { b ->
b.configureCapture(CAPTURE_WIDTH, CAPTURE_HEIGHT)
b.startServer(SERVER_PORT)
}
screenCapture = ScreenCapture(
projection = mediaProjection!!,
metrics = metrics,
bridge = bridge!!,
targetWidth = CAPTURE_WIDTH,
targetHeight = CAPTURE_HEIGHT,
targetFps = CAPTURE_FPS,
).also { it.start() }
Log.i(TAG, "LedGrab service started (MediaProjection) — web UI at $url")
}
override fun onDestroy() {
screenCapture?.stop()
screenCapture = null
rootCapture?.stop()
rootCapture = null
bridge?.stopServer()
bridge = null
@@ -54,18 +54,40 @@ class MainActivity : Activity() {
.getPackageInfo(packageName, 0).versionName
versionText.text = "v$versionName"
toggleButton.setOnClickListener { requestMediaProjection() }
toggleButton.setOnClickListener { startCapture() }
stopButtonRunning.setOnClickListener { stopCaptureService() }
updateUI()
}
/**
* Decide whether to go through the MediaProjection consent flow or
* jump straight into root capture. Root check is fast but may block
* briefly the first time Magisk shows its grant dialog — running it
* on the UI thread is acceptable because we're responding to a
* button press and we want to block until the user answers.
*/
private fun startCapture() {
if (Root.requestGrant()) {
Log.i(TAG, "Root available — skipping MediaProjection consent")
startRootCaptureService()
} else {
requestMediaProjection()
}
}
private fun requestMediaProjection() {
val manager = getSystemService(MEDIA_PROJECTION_SERVICE) as MediaProjectionManager
@Suppress("DEPRECATION")
startActivityForResult(manager.createScreenCaptureIntent(), REQUEST_MEDIA_PROJECTION)
}
private fun startRootCaptureService() {
startForegroundService(CaptureService.createRootIntent(this))
serviceRunning = true
updateUI()
}
@Deprecated("Using deprecated API for plain Activity compatibility")
override fun onActivityResult(requestCode: Int, resultCode: Int, data: Intent?) {
super.onActivityResult(requestCode, resultCode, data)
@@ -30,6 +30,17 @@ class PythonBridge(private val context: Context) {
Log.i(TAG, "MediaProjection engine configured: ${width}x${height}")
}
/**
* Configure the root screenrecord engine with screen dimensions.
* Used instead of [configureCapture] when root is available.
*/
fun configureRootCapture(width: Int, height: Int) {
val py = Python.getInstance()
val engine = py.getModule("ledgrab.core.capture_engines.root_screenrecord_engine")
engine.callAttr("configure", width, height)
Log.i(TAG, "Root screenrecord engine configured: ${width}x${height}")
}
/**
* Start the LedGrab FastAPI server on a background thread.
*
@@ -98,5 +109,24 @@ class PythonBridge(private val context: Context) {
}
}
/**
* Push a frame produced by the root `screenrecord` pipeline.
*
* Routed to a separate Python module so the two capture paths stay
* independently introspectable (engine priority, availability flags,
* dashboard diagnostics).
*/
fun pushRootFrame(rgbaBytes: ByteArray, width: Int, height: Int) {
if (!running) 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}")
}
}
val isRunning: Boolean get() = running
}
@@ -0,0 +1,82 @@
package com.ledgrab.android
import android.util.Log
import java.io.BufferedReader
import java.io.InputStreamReader
import java.util.concurrent.TimeUnit
/**
* Lightweight root-access utilities.
*
* Detection is two-phase:
* 1. Cheap check for a `su` binary in common paths (no process spawn).
* 2. On demand, a real `su -c id` execution that returns true only when
* the shell actually runs as UID 0. This triggers Magisk's grant
* dialog the first time, exactly like any other root request.
*
* The heavier check is cached after it succeeds so we don't ask Magisk
* repeatedly mid-session.
*/
object Root {
private const val TAG = "Root"
private val SU_PATHS = listOf(
"/system/bin/su",
"/system/xbin/su",
"/sbin/su",
"/su/bin/su",
"/debug_ramdisk/su",
"/vendor/bin/su",
)
@Volatile private var cachedGranted: Boolean? = null
/** Cheap probe: true if a known `su` binary exists. Doesn't run anything. */
@JvmStatic
fun looksRooted(): Boolean = SU_PATHS.any { java.io.File(it).exists() }
/**
* Actually exercise `su`. Triggers Magisk's grant dialog on first call.
* Returns true if the shell reports UID 0 within the timeout; false
* otherwise (no root, user denied, or timeout).
*/
@JvmStatic
fun requestGrant(timeoutSeconds: Long = 10): Boolean {
cachedGranted?.let { return it }
if (!looksRooted()) {
cachedGranted = false
return false
}
val granted = try {
val process = Runtime.getRuntime().exec(arrayOf("su", "-c", "id"))
val finished = process.waitFor(timeoutSeconds, TimeUnit.SECONDS)
if (!finished) {
process.destroyForcibly()
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
}
} catch (e: Exception) {
Log.w(TAG, "su invocation failed: ${e.message}")
false
}
cachedGranted = granted
return granted
}
/** Forget the cached grant result — useful if Magisk permission was revoked. */
@JvmStatic
fun invalidateCache() {
cachedGranted = null
}
}
@@ -0,0 +1,231 @@
package com.ledgrab.android
import android.graphics.PixelFormat
import android.media.ImageReader
import android.media.MediaCodec
import android.media.MediaFormat
import android.os.Handler
import android.os.HandlerThread
import android.util.Log
import java.io.InputStream
/**
* Root-only screen capture backed by `/system/bin/screenrecord`.
*
* When the device is rooted (Magisk), we spawn ``su -c screenrecord
* --output-format=h264 …`` and read the encoder's H.264 bitstream from
* stdout. A MediaCodec decoder is configured to render into an
* ImageReader's Surface, and the ImageReader's `onImageAvailable`
* callback feeds RGBA bytes back to the Python pipeline — same sink as
* the MediaProjection path, just a different source.
*
* Why bother: MediaProjection forces Android to draw a persistent
* capture indicator overlay (unavoidable on stock Android 14+). The
* root path sidesteps that entirely because `screenrecord` runs as
* system UID through `su`. It also skips the one-time MediaProjection
* consent dialog, which matters on TV-only setups where keyboard input
* is awkward.
*/
class RootScreenrecord(
private val bridge: PythonBridge,
private val width: Int = 480,
private val height: Int = 270,
private val bitRate: Int = 4_000_000,
private val fps: Int = 30,
) {
companion object {
private const val TAG = "RootScreenrecord"
private const val MIME_TYPE = MediaFormat.MIMETYPE_VIDEO_AVC
private const val INPUT_CHUNK = 64 * 1024
}
private var process: Process? = null
private var decoder: MediaCodec? = null
private var imageReader: ImageReader? = null
private var readerThread: HandlerThread? = null
private var inputThread: Thread? = null
private var outputThread: Thread? = null
@Volatile private var running = false
@Volatile private var framesDelivered = 0
/** True once at least one frame has reached the Python bridge. */
val hasProducedFrame: Boolean get() = framesDelivered > 0
/**
* Start the capture pipeline. Returns false if `su`/`screenrecord`
* couldn't be launched at all; true doesn't guarantee frames will
* actually flow (the caller should monitor [hasProducedFrame] and
* be ready to fall back to MediaProjection on timeout).
*/
fun start(): Boolean {
if (running) return true
running = true
try {
imageReader = buildImageReader()
decoder = buildDecoder(imageReader!!)
process = spawnScreenrecord() ?: run {
stop()
return false
}
startInputPump(process!!.inputStream, decoder!!)
startOutputDrain(decoder!!)
Log.i(TAG, "Root capture pipeline started (${width}x$height @ ${fps}fps)")
return true
} catch (e: Exception) {
Log.e(TAG, "Failed to start root capture", e)
stop()
return false
}
}
/** Stop everything and release resources. Idempotent. */
fun stop() {
running = false
inputThread?.interrupt()
outputThread?.interrupt()
inputThread = null
outputThread = null
runCatching { process?.destroy() }
process = null
runCatching { decoder?.stop() }
runCatching { decoder?.release() }
decoder = null
runCatching { imageReader?.close() }
imageReader = null
readerThread?.quitSafely()
readerThread = null
Log.i(TAG, "Root capture pipeline stopped (frames delivered: $framesDelivered)")
}
private fun buildImageReader(): ImageReader {
val thread = HandlerThread("RootReader").apply { start() }
readerThread = thread
val handler = Handler(thread.looper)
val reader = ImageReader.newInstance(width, height, PixelFormat.RGBA_8888, 2)
reader.setOnImageAvailableListener({ r ->
val image = r.acquireLatestImage() ?: return@setOnImageAvailableListener
try {
val plane = image.planes[0]
val buffer = plane.buffer
val rowStride = plane.rowStride
val pixelStride = plane.pixelStride
val bytes = if (rowStride == width * pixelStride) {
ByteArray(buffer.remaining()).also { buffer.get(it) }
} else {
// Strip row padding — common when width isn't a multiple of 16.
val rowBytes = width * pixelStride
ByteArray(width * height * 4).also { out ->
for (row in 0 until height) {
buffer.position(row * rowStride)
buffer.get(out, row * rowBytes, rowBytes)
}
}
}
bridge.pushRootFrame(bytes, width, height)
framesDelivered += 1
} catch (e: Exception) {
Log.w(TAG, "Root frame delivery failed: ${e.message}")
} finally {
image.close()
}
}, handler)
return reader
}
private fun buildDecoder(reader: ImageReader): MediaCodec {
val format = MediaFormat.createVideoFormat(MIME_TYPE, width, height).apply {
setInteger(MediaFormat.KEY_FRAME_RATE, fps)
}
val codec = MediaCodec.createDecoderByType(MIME_TYPE)
codec.configure(format, reader.surface, null, 0)
codec.start()
return codec
}
private fun spawnScreenrecord(): Process? {
val cmd = buildString {
append("screenrecord")
append(" --output-format=h264")
append(" --size=${width}x$height")
append(" --bit-rate=$bitRate")
// Time limit 0 isn't supported; the largest accepted is 180s.
// We restart the process ourselves if it exits early.
append(" --time-limit=180")
append(" -")
}
return try {
Runtime.getRuntime().exec(arrayOf("su", "-c", cmd))
} catch (e: Exception) {
Log.e(TAG, "Failed to spawn `su -c screenrecord`: ${e.message}")
null
}
}
private fun startInputPump(stream: InputStream, codec: MediaCodec) {
inputThread = Thread({
val buf = ByteArray(INPUT_CHUNK)
try {
while (running) {
val n = stream.read(buf)
if (n <= 0) {
Log.w(TAG, "screenrecord stdout closed (EOF)")
break
}
var offset = 0
while (offset < n && running) {
val index = codec.dequeueInputBuffer(50_000)
if (index < 0) continue
val inputBuffer = codec.getInputBuffer(index) ?: continue
inputBuffer.clear()
val chunk = minOf(n - offset, inputBuffer.capacity())
inputBuffer.put(buf, offset, chunk)
codec.queueInputBuffer(
index,
0,
chunk,
System.nanoTime() / 1_000,
0,
)
offset += chunk
}
}
} catch (_: InterruptedException) {
// Expected on stop()
} catch (e: Exception) {
Log.w(TAG, "Input pump error: ${e.message}")
}
}, "RootDecoderInput").also { it.start() }
}
private fun startOutputDrain(codec: MediaCodec) {
outputThread = Thread({
val info = MediaCodec.BufferInfo()
try {
while (running) {
val index = codec.dequeueOutputBuffer(info, 50_000)
if (index >= 0) {
// `true` = render to the configured surface (ImageReader),
// which triggers onImageAvailable on the reader's handler.
codec.releaseOutputBuffer(index, true)
if (info.flags and MediaCodec.BUFFER_FLAG_END_OF_STREAM != 0) {
Log.i(TAG, "Decoder reported EOS")
break
}
}
}
} catch (_: InterruptedException) {
// Expected on stop()
} catch (e: Exception) {
Log.w(TAG, "Output drain error: ${e.message}")
}
}, "RootDecoderOutput").also { it.start() }
}
}