diff --git a/android/app/src/main/java/com/ledgrab/android/CaptureService.kt b/android/app/src/main/java/com/ledgrab/android/CaptureService.kt index cb3fe4b..b2aa626 100644 --- a/android/app/src/main/java/com/ledgrab/android/CaptureService.kt +++ b/android/app/src/main/java/com/ledgrab/android/CaptureService.kt @@ -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 diff --git a/android/app/src/main/java/com/ledgrab/android/MainActivity.kt b/android/app/src/main/java/com/ledgrab/android/MainActivity.kt index ba7375d..4292911 100644 --- a/android/app/src/main/java/com/ledgrab/android/MainActivity.kt +++ b/android/app/src/main/java/com/ledgrab/android/MainActivity.kt @@ -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) diff --git a/android/app/src/main/java/com/ledgrab/android/PythonBridge.kt b/android/app/src/main/java/com/ledgrab/android/PythonBridge.kt index 0798fbd..ddc4dfd 100644 --- a/android/app/src/main/java/com/ledgrab/android/PythonBridge.kt +++ b/android/app/src/main/java/com/ledgrab/android/PythonBridge.kt @@ -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 } diff --git a/android/app/src/main/java/com/ledgrab/android/Root.kt b/android/app/src/main/java/com/ledgrab/android/Root.kt new file mode 100644 index 0000000..34fe5bb --- /dev/null +++ b/android/app/src/main/java/com/ledgrab/android/Root.kt @@ -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 + } +} diff --git a/android/app/src/main/java/com/ledgrab/android/RootScreenrecord.kt b/android/app/src/main/java/com/ledgrab/android/RootScreenrecord.kt new file mode 100644 index 0000000..d4d5d48 --- /dev/null +++ b/android/app/src/main/java/com/ledgrab/android/RootScreenrecord.kt @@ -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() } + } +} diff --git a/server/src/ledgrab/core/capture_engines/__init__.py b/server/src/ledgrab/core/capture_engines/__init__.py index 1d1e841..cffcc6f 100644 --- a/server/src/ledgrab/core/capture_engines/__init__.py +++ b/server/src/ledgrab/core/capture_engines/__init__.py @@ -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"] diff --git a/server/src/ledgrab/core/capture_engines/root_screenrecord_engine.py b/server/src/ledgrab/core/capture_engines/root_screenrecord_engine.py new file mode 100644 index 0000000..ca1c42f --- /dev/null +++ b/server/src/ledgrab/core/capture_engines/root_screenrecord_engine.py @@ -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)