From bc0d6302d3c095f5f5ccfc3b64d729b29d9aaf23 Mon Sep 17 00:00:00 2001 From: Seb3thehacker Date: Sun, 4 Oct 2026 14:24:06 -0700 Subject: [PATCH] fix touch on head units with a second screen LIVI lists an empty input for its cluster display before the real touchscreen, so every tap was ignored. Pick the video and input that belong to the main screen. --- .../seb3thehacker/gearslip/GearslipRunner.kt | 4 +- .../gearslip/ServiceDiscovery.kt | 34 ++++++++++--- .../gearslip/ServiceDiscoveryDisplayTest.kt | 51 +++++++++++++++++++ 3 files changed, 81 insertions(+), 8 deletions(-) create mode 100644 gearslip/src/test/java/app/seb3thehacker/gearslip/ServiceDiscoveryDisplayTest.kt diff --git a/gearslip/src/main/java/app/seb3thehacker/gearslip/GearslipRunner.kt b/gearslip/src/main/java/app/seb3thehacker/gearslip/GearslipRunner.kt index 69b3c6b..316ca55 100644 --- a/gearslip/src/main/java/app/seb3thehacker/gearslip/GearslipRunner.kt +++ b/gearslip/src/main/java/app/seb3thehacker/gearslip/GearslipRunner.kt @@ -383,7 +383,7 @@ class GearslipRunner( videoChannelId = video.serviceId videoConfigs = video.configs - log.i("video service: channel=${video.serviceId} codec=${video.codecName}") + log.i("video service: channel=${video.serviceId} codec=${video.codecName} display=${video.displayId}") video.configs.forEachIndexed { index, config -> log.i(" config[$index] = $config") } @@ -395,7 +395,7 @@ class GearslipRunner( startAudioChannel(serviceDiscoveryResponse) startSensorChannel(serviceDiscoveryResponse) - val input = ServiceDiscovery.findInputService(serviceDiscoveryResponse) + val input = ServiceDiscovery.findInputService(serviceDiscoveryResponse, video.displayId) if (input == null) { log.w("no input service advertised - projection will be output-only") } else { diff --git a/gearslip/src/main/java/app/seb3thehacker/gearslip/ServiceDiscovery.kt b/gearslip/src/main/java/app/seb3thehacker/gearslip/ServiceDiscovery.kt index 8c5c0c9..b5fcab2 100644 --- a/gearslip/src/main/java/app/seb3thehacker/gearslip/ServiceDiscovery.kt +++ b/gearslip/src/main/java/app/seb3thehacker/gearslip/ServiceDiscovery.kt @@ -51,7 +51,8 @@ object ServiceDiscovery { "$resolutionName @ $frameRateName, density=$density, margin=${widthMargin}x$heightMargin" } - class VideoService(val serviceId: Int, val codecType: Int, val configs: List) { + /** [displayId] is MediaSinkService field 6: 0, the default, is the main screen. */ + class VideoService(val serviceId: Int, val codecType: Int, val configs: List, val displayId: Int = 0) { val codecName: String get() = CODECS[codecType] ?: "unknown($codecType)" } @@ -131,9 +132,23 @@ object ServiceDiscovery { val touchWidth: Int, val touchHeight: Int, val keycodesSupported: List = emptyList(), + /** InputSourceService field 5: which screen this input belongs to, 0 for the main one. */ + val displayId: Int = 0, ) - fun findInputService(response: ByteArray): InputService? { + /** + * The input for the screen Gearslip projects to ([displayId]). A head unit with two screens + * offers an input service for each: LIVI lists an empty one for its second screen first, + * and taking that one left every touch on the real touchscreen ignored. + */ + fun findInputService(response: ByteArray, displayId: Int = 0): InputService? { + val inputs = inputServices(response) + return inputs.firstOrNull { it.displayId == displayId } + ?: inputs.firstOrNull { it.touchWidth > 0 && it.touchHeight > 0 } + ?: inputs.firstOrNull() + } + + private fun inputServices(response: ByteArray): List = buildList { for (channel in Wire.allBytes(Wire.fields(response), 1)) { val service = Wire.fields(channel) val id = Wire.varint(service, 1)?.toInt() ?: continue @@ -147,9 +162,8 @@ object ServiceDiscovery { val keycodes = inputFields.filter { it.number == 1 }.flatMap { f -> f.bytes?.let(::packedVarints) ?: listOf(f.varint.toInt()) } - return InputService(id, width, height, keycodes) + add(InputService(id, width, height, keycodes, Wire.varint(inputFields, 5)?.toInt() ?: 0)) } - return null } /** Decodes a `[packed=true]` repeated varint field's raw bytes into individual values. */ @@ -169,7 +183,16 @@ object ServiceDiscovery { } } + /** + * The main screen's video sink. A head unit with a second screen, such as LIVI with a + * cluster display, offers a sink for each; the one without a display id (0) is the main one. + */ fun findVideoService(response: ByteArray): VideoService? { + val sinks = videoServices(response) + return sinks.firstOrNull { it.displayId == 0 } ?: sinks.firstOrNull() + } + + private fun videoServices(response: ByteArray): List = buildList { for (channel in Wire.allBytes(Wire.fields(response), 1)) { val service = Wire.fields(channel) val id = Wire.varint(service, 1)?.toInt() ?: continue @@ -190,9 +213,8 @@ object ServiceDiscovery { heightMargin = Wire.varint(f, 4)?.toInt() ?: 0, ) } - return VideoService(id, codecType, configs) + add(VideoService(id, codecType, configs, Wire.varint(sinkFields, 6)?.toInt() ?: 0)) } - return null } /** diff --git a/gearslip/src/test/java/app/seb3thehacker/gearslip/ServiceDiscoveryDisplayTest.kt b/gearslip/src/test/java/app/seb3thehacker/gearslip/ServiceDiscoveryDisplayTest.kt new file mode 100644 index 0000000..6287e23 --- /dev/null +++ b/gearslip/src/test/java/app/seb3thehacker/gearslip/ServiceDiscoveryDisplayTest.kt @@ -0,0 +1,51 @@ +package app.seb3thehacker.gearslip + +import org.junit.Assert.assertEquals +import org.junit.Test + +/** + * LIVI 2.0.1 offers a second screen alongside the main one, and lists that screen's input first: + * channel 3: video sink, no display id (the main screen) + * channel 19: video sink, display_id = 1, display_type = 1 (a cluster) + * channel 20: input service with only display_id = 1 + * channel 8: input service with a 1280x720 touchscreen and keys (the main screen) + * Taking the first input service left every touch on the main screen ignored. + */ +class ServiceDiscoveryDisplayTest { + + private fun varintField(field: Int, value: Long) = Protobuf.varintField(field, value) + + private fun lenField(field: Int, body: ByteArray): ByteArray = + Protobuf.varint(((field shl 3) or 2).toLong()) + Protobuf.varint(body.size.toLong()) + body + + private fun videoSink(id: Int, displayId: Int?): ByteArray { + val config = varintField(1, 2L) + varintField(2, 1L) + varintField(5, 180L) + var sink = varintField(1, 3L) + lenField(4, config) + if (displayId != null) sink += varintField(6, displayId.toLong()) + varintField(7, 1L) + return lenField(1, varintField(1, id.toLong()) + lenField(3, sink)) + } + + private fun input(id: Int, body: ByteArray) = lenField(1, varintField(1, id.toLong()) + lenField(4, body)) + + private fun liviResponse(): ByteArray = + videoSink(3, null) + + videoSink(19, 1) + + input(20, varintField(5, 1L)) + + input(8, lenField(1, Protobuf.varint(3L) + Protobuf.varint(4L)) + lenField(2, varintField(1, 1280L) + varintField(2, 720L))) + + @Test + fun picksTheMainScreensVideo() { + val video = ServiceDiscovery.findVideoService(liviResponse())!! + assertEquals(3, video.serviceId) + assertEquals(0, video.displayId) + } + + @Test + fun picksTheInputForTheProjectedScreen() { + val input = ServiceDiscovery.findInputService(liviResponse(), displayId = 0)!! + assertEquals(8, input.serviceId) + assertEquals(1280, input.touchWidth) + assertEquals(720, input.touchHeight) + assertEquals(listOf(3, 4), input.keycodesSupported) + } +}