feat(android): root-based screen capture bypassing MediaProjection
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:
@@ -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() }
|
||||
}
|
||||
}
|
||||
@@ -86,6 +86,18 @@ try:
|
||||
except ImportError:
|
||||
_has_mediaprojection = False
|
||||
|
||||
# ── Android root screenrecord (rooted Magisk devices) ───────────────
|
||||
|
||||
try:
|
||||
from ledgrab.core.capture_engines.root_screenrecord_engine import (
|
||||
RootScreenrecordEngine,
|
||||
RootScreenrecordCaptureStream,
|
||||
)
|
||||
|
||||
_has_root_screenrecord = True
|
||||
except ImportError:
|
||||
_has_root_screenrecord = False
|
||||
|
||||
# ── Demo / always available ─────────────────────────────────────────
|
||||
|
||||
from ledgrab.core.capture_engines.demo_engine import DemoCaptureEngine, DemoCaptureStream
|
||||
@@ -108,6 +120,8 @@ if _has_camera:
|
||||
EngineRegistry.register(CameraEngine)
|
||||
if _has_mediaprojection:
|
||||
EngineRegistry.register(MediaProjectionEngine)
|
||||
if _has_root_screenrecord:
|
||||
EngineRegistry.register(RootScreenrecordEngine)
|
||||
EngineRegistry.register(DemoCaptureEngine)
|
||||
|
||||
# ── Public API ──────────────────────────────────────────────────────
|
||||
@@ -138,3 +152,5 @@ if _has_camera:
|
||||
__all__ += ["CameraEngine", "CameraCaptureStream"]
|
||||
if _has_mediaprojection:
|
||||
__all__ += ["MediaProjectionEngine", "MediaProjectionCaptureStream"]
|
||||
if _has_root_screenrecord:
|
||||
__all__ += ["RootScreenrecordEngine", "RootScreenrecordCaptureStream"]
|
||||
|
||||
@@ -0,0 +1,170 @@
|
||||
"""Android root-only screen capture engine (screenrecord + MediaCodec).
|
||||
|
||||
Mirrors :mod:`mediaprojection_engine` but represents a different source:
|
||||
Kotlin's :class:`RootScreenrecord` spawns ``su -c screenrecord --output-format=h264``
|
||||
and pipes the bitstream through a MediaCodec decoder into an ImageReader
|
||||
Surface. Frames reach this module via :func:`push_frame`, delivered by
|
||||
:meth:`PythonBridge.pushRootFrame`.
|
||||
|
||||
Why a separate engine and not a flag on MediaProjection: the root path
|
||||
avoids both the one-time consent dialog *and* the persistent capture
|
||||
indicator that Android 14+ draws while MediaProjection is active, so
|
||||
it's meaningfully different from the user's perspective. Keeping it as
|
||||
a distinct engine also lets the dashboard show which backend is live
|
||||
and lets us give it a higher priority so the factory selects it
|
||||
automatically when available.
|
||||
"""
|
||||
|
||||
import queue
|
||||
from typing import Any, Dict, List, Optional
|
||||
|
||||
import numpy as np
|
||||
|
||||
from ledgrab.core.capture_engines.base import (
|
||||
CaptureEngine,
|
||||
CaptureStream,
|
||||
DisplayInfo,
|
||||
ScreenCapture,
|
||||
)
|
||||
from ledgrab.utils import get_logger
|
||||
|
||||
logger = get_logger(__name__)
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Frame queue — the bridge between Kotlin and Python
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
_frame_queue: queue.Queue["ScreenCapture"] = queue.Queue(maxsize=2)
|
||||
_display_info: Optional[DisplayInfo] = None
|
||||
_active = False
|
||||
_frames_received = 0
|
||||
# screenrecord emits a full bitstream every frame (keyframes aside), so
|
||||
# the decoder output is always fresh — no need for a "last frame" cache
|
||||
# like MediaProjection. We still keep one just in case the consumer polls
|
||||
# faster than the decoder produces.
|
||||
_last_frame: Optional["ScreenCapture"] = None
|
||||
|
||||
|
||||
def configure(width: int, height: int) -> None:
|
||||
"""Set display dimensions. Called from Kotlin before server start."""
|
||||
global _display_info, _active, _last_frame, _frames_received
|
||||
while not _frame_queue.empty():
|
||||
try:
|
||||
_frame_queue.get_nowait()
|
||||
except queue.Empty:
|
||||
break
|
||||
_last_frame = None
|
||||
_frames_received = 0
|
||||
_display_info = DisplayInfo(
|
||||
index=0,
|
||||
name="Android TV Screen (root)",
|
||||
width=width,
|
||||
height=height,
|
||||
x=0,
|
||||
y=0,
|
||||
is_primary=True,
|
||||
refresh_rate=30,
|
||||
)
|
||||
_active = True
|
||||
logger.info("Root screenrecord engine configured: %dx%d", width, height)
|
||||
|
||||
|
||||
def push_frame(rgba_bytes: bytes, width: int, height: int) -> None:
|
||||
"""Push a decoded frame from Kotlin into the Python pipeline."""
|
||||
global _frames_received, _last_frame
|
||||
_frames_received += 1
|
||||
if _frames_received == 1 or _frames_received % 100 == 0:
|
||||
logger.info("Root screenrecord: received %d frames", _frames_received)
|
||||
|
||||
rgba = np.frombuffer(rgba_bytes, dtype=np.uint8).reshape((height, width, 4))
|
||||
rgb = rgba[:, :, :3].copy()
|
||||
|
||||
frame = ScreenCapture(image=rgb, width=width, height=height, display_index=0)
|
||||
_last_frame = frame
|
||||
|
||||
try:
|
||||
_frame_queue.put_nowait(frame)
|
||||
except queue.Full:
|
||||
try:
|
||||
_frame_queue.get_nowait()
|
||||
except queue.Empty:
|
||||
pass
|
||||
try:
|
||||
_frame_queue.put_nowait(frame)
|
||||
except queue.Full:
|
||||
pass
|
||||
|
||||
|
||||
def shutdown() -> None:
|
||||
"""Deactivate the engine. Called when the Android app stops."""
|
||||
global _active
|
||||
_active = False
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# CaptureStream / CaptureEngine
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class RootScreenrecordCaptureStream(CaptureStream):
|
||||
"""Reads frames pushed by Kotlin's RootScreenrecord from the module queue."""
|
||||
|
||||
def __init__(self, display_index: int, config: Dict[str, Any]):
|
||||
super().__init__(display_index, config)
|
||||
|
||||
def initialize(self) -> None:
|
||||
if self._initialized:
|
||||
return
|
||||
if not _active:
|
||||
raise RuntimeError(
|
||||
"Root screenrecord engine not configured. "
|
||||
"This engine is only available on rooted Android devices."
|
||||
)
|
||||
self._initialized = True
|
||||
logger.info("Root screenrecord capture stream initialized")
|
||||
|
||||
def capture_frame(self) -> Optional[ScreenCapture]:
|
||||
if not self._initialized:
|
||||
self.initialize()
|
||||
try:
|
||||
return _frame_queue.get(timeout=0.1)
|
||||
except queue.Empty:
|
||||
return _last_frame
|
||||
|
||||
def cleanup(self) -> None:
|
||||
while not _frame_queue.empty():
|
||||
try:
|
||||
_frame_queue.get_nowait()
|
||||
except queue.Empty:
|
||||
break
|
||||
self._initialized = False
|
||||
logger.info("Root screenrecord capture stream cleaned up")
|
||||
|
||||
|
||||
class RootScreenrecordEngine(CaptureEngine):
|
||||
"""Root-only Android capture engine. Preferred over MediaProjection."""
|
||||
|
||||
ENGINE_TYPE = "root_screenrecord"
|
||||
# Higher than MediaProjection (100) — factory picks this when available.
|
||||
ENGINE_PRIORITY = 110
|
||||
HAS_OWN_DISPLAYS = True
|
||||
|
||||
@classmethod
|
||||
def is_available(cls) -> bool:
|
||||
return _active and _display_info is not None
|
||||
|
||||
@classmethod
|
||||
def get_default_config(cls) -> Dict[str, Any]:
|
||||
return {}
|
||||
|
||||
@classmethod
|
||||
def get_available_displays(cls) -> List[DisplayInfo]:
|
||||
if _display_info is not None:
|
||||
return [_display_info]
|
||||
return []
|
||||
|
||||
@classmethod
|
||||
def create_stream(
|
||||
cls, display_index: int, config: Dict[str, Any]
|
||||
) -> RootScreenrecordCaptureStream:
|
||||
return RootScreenrecordCaptureStream(display_index, config)
|
||||
Reference in New Issue
Block a user