From 32bef88a759b986c304bb741fd7422103d13ae1a Mon Sep 17 00:00:00 2001 From: Seb3thehacker Date: Mon, 19 Oct 2026 23:15:53 -0700 Subject: [PATCH] preview: draw the car screen straight into a SurfaceView Each frame used to be copied into two new full-size bitmaps on the main thread, so the bigger the preview, the further taps lagged. The display now draws into the preview's own surface, the way the car's encoder gets it, and turning the phone hands the surface over without restarting the car UI. --- .../seb3thehacker/gearslip/ScreenProjector.kt | 10 ++- .../gearslip/ui/CarPreviewScreen.kt | 83 ++++++------------- 2 files changed, 34 insertions(+), 59 deletions(-) diff --git a/gearslip/src/main/java/app/seb3thehacker/gearslip/ScreenProjector.kt b/gearslip/src/main/java/app/seb3thehacker/gearslip/ScreenProjector.kt index c9ffee5..75d873d 100644 --- a/gearslip/src/main/java/app/seb3thehacker/gearslip/ScreenProjector.kt +++ b/gearslip/src/main/java/app/seb3thehacker/gearslip/ScreenProjector.kt @@ -51,7 +51,7 @@ class ScreenProjector(private val context: Context) { * preview passes "gearslip-preview". */ fun start( - surface: Surface, + surface: Surface?, width: Int, height: Int, densityDpi: Int, @@ -219,6 +219,14 @@ class ScreenProjector(private val context: Context) { target.dispatchKeyEvent(event) } + /** + * Points the display at another surface, or none, without restarting what it shows. The + * preview's SurfaceView comes and goes as the phone turns; the car UI on it carries on. + */ + fun setSurface(surface: Surface?) { + virtualDisplay?.surface = surface + } + fun stop() { runCatching { presentation?.dismiss() } runCatching { owner?.destroy() } diff --git a/gearslip/src/main/java/app/seb3thehacker/gearslip/ui/CarPreviewScreen.kt b/gearslip/src/main/java/app/seb3thehacker/gearslip/ui/CarPreviewScreen.kt index 6a4e68c..1379319 100644 --- a/gearslip/src/main/java/app/seb3thehacker/gearslip/ui/CarPreviewScreen.kt +++ b/gearslip/src/main/java/app/seb3thehacker/gearslip/ui/CarPreviewScreen.kt @@ -4,15 +4,8 @@ import android.app.Activity import android.content.Context import android.content.ContextWrapper import android.content.res.Configuration -import android.graphics.Bitmap -import android.graphics.PixelFormat -import android.hardware.HardwareBuffer -import android.media.ImageReader -import android.os.Handler -import android.os.Looper import android.view.MotionEvent import androidx.activity.compose.BackHandler -import androidx.compose.foundation.Image import androidx.compose.foundation.background import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.BoxScope @@ -39,9 +32,7 @@ import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color -import androidx.compose.ui.graphics.asImageBitmap import androidx.compose.ui.input.pointer.pointerInteropFilter -import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.layout.onSizeChanged import androidx.compose.ui.platform.LocalConfiguration import androidx.compose.ui.platform.LocalContext @@ -59,7 +50,7 @@ import app.seb3thehacker.gearslip.car.CarUi * Runs the real car UI on the bench and shows it on the phone. * * This is the same pipeline the car gets - a VirtualDisplay hosting a Presentation, driven by - * the same [ScreenProjector] and fed the same touches - with an ImageReader standing in for the + * the same [ScreenProjector] and fed the same touches - with a SurfaceView standing in for the * H.264 encoder. That matters for anything composited rather than drawn by Compose, such as the * SurfaceView a templated app draws its map into: if it appears here, it will reach the car. * @@ -69,7 +60,6 @@ import app.seb3thehacker.gearslip.car.CarUi fun CarPreviewScreen(onBack: () -> Unit) { val context = LocalContext.current val frame by CarEnvironment.frame.collectAsStateWithLifecycle() - var bitmap by remember { mutableStateOf(null) } var viewSize by remember { mutableStateOf(0 to 0) } // Tied to how the phone itself is held, not a button: turn it sideways and the preview goes // fullscreen, turn it back and the phone's own chrome (the Back/title above the frame) returns. @@ -83,35 +73,11 @@ fun CarPreviewScreen(onBack: () -> Unit) { DisposableEffect(width, height, density) { CarEnvironment.start(context) - - // COMPOSER_OVERLAY is what makes SurfaceFlinger willing to composite into this reader; - // CPU_READ_OFTEN is what makes the resulting buffer lockable, which is how the frame - // gets copied into a Bitmap. Without both, frames arrive but every copy fails. - val reader = ImageReader.newInstance( - width, height, PixelFormat.RGBA_8888, 3, - HardwareBuffer.USAGE_COMPOSER_OVERLAY or - HardwareBuffer.USAGE_GPU_SAMPLED_IMAGE or - HardwareBuffer.USAGE_CPU_READ_OFTEN, - ) - val handler = Handler(Looper.getMainLooper()) - reader.setOnImageAvailableListener({ source -> - val image = runCatching { source.acquireLatestImage() }.getOrNull() ?: return@setOnImageAvailableListener - try { - bitmap = image.toBitmap(width, height) - } catch (t: Throwable) { - GearslipLog.e("preview: could not copy a frame", t) - } finally { - image.close() - } - }, handler) - - projector.start(reader.surface, width, height, density, name = "gearslip-preview") { CarUi() } - - onDispose { - projector.stop() - reader.close() - bitmap = null - } + // No surface yet: the SurfaceView below hands its own over once it exists, and the + // display draws straight into it. Copying each frame into a Bitmap instead cost two + // full-size allocations per frame on the main thread, and taps lagged with the size. + projector.start(null, width, height, density, name = "gearslip-preview") { CarUi() } + onDispose { projector.stop() } } // The frame is drawn the same way in both layouts; only what surrounds it differs. @@ -140,14 +106,27 @@ fun CarPreviewScreen(onBack: () -> Unit) { }, contentAlignment = Alignment.Center, ) { - bitmap?.let { - Image( - it.asImageBitmap(), - contentDescription = "Car screen", + androidx.compose.runtime.key(width, height) { + androidx.compose.ui.viewinterop.AndroidView( + factory = { viewContext -> + android.view.SurfaceView(viewContext).apply { + // The car's own size; the compositor scales it to the view. + holder.setFixedSize(width, height) + holder.addCallback(object : android.view.SurfaceHolder.Callback { + override fun surfaceCreated(holder: android.view.SurfaceHolder) = + projector.setSurface(holder.surface) + + override fun surfaceChanged(holder: android.view.SurfaceHolder, format: Int, w: Int, h: Int) = + projector.setSurface(holder.surface) + + override fun surfaceDestroyed(holder: android.view.SurfaceHolder) = + projector.setSurface(null) + }) + } + }, modifier = Modifier.fillMaxSize(), - contentScale = ContentScale.Fit, ) - } ?: Text("Waiting for a frame…", color = Color.White) + } } } @@ -250,18 +229,6 @@ private fun Int.toCarAction(): Int = when (this) { * Copies one frame out of the reader. The reader's rows are padded to a hardware-friendly * stride, so the copy is made at the padded width and then cropped back. */ -private fun android.media.Image.toBitmap(width: Int, height: Int): Bitmap { - val plane = planes[0] - val pixelStride = plane.pixelStride - val rowPadding = plane.rowStride - pixelStride * width - val padded = Bitmap.createBitmap( - width + rowPadding / pixelStride, height, Bitmap.Config.ARGB_8888, - ) - padded.copyPixelsFromBuffer(plane.buffer) - return if (padded.width == width) padded - else Bitmap.createBitmap(padded, 0, 0, width, height).also { padded.recycle() } -} - /** * The size the preview draws at. The last car's frame by default; this device's whole screen, * for a head unit running Gearslip on its own (issue #12); or a size typed into Settings. The