Validate input and correct navigation and sensor services

This commit is contained in:
Iscle authored and Seb3thehacker committed 2026-10-21 12:30:38 -07:00
1 parent c04c35b5cd
commit 6efdad5e53
11 files changed
+355 -138

No files matched your search

@@ -18,6 +18,7 @@ import app.seb3thehacker.gearslip.car.CarSensors
import app.seb3thehacker.gearslip.car.CarServices
import app.seb3thehacker.gearslip.car.CarSettings
import java.io.IOException
import app.seb3thehacker.gearslip.protocol.InputProtocol
import java.io.InputStream
import java.io.OutputStream
import java.util.concurrent.Executors
@@ -660,12 +661,15 @@ class GearslipRunner(
/** Turn-by-turn for the instrument cluster: the next turn, its road, and how far. */
private fun startClusterNavChannel(serviceDiscoveryResponse: ByteArray) {
val channel = ServiceDiscovery.findNavStatusChannel(serviceDiscoveryResponse) ?: run {
val navigation = ServiceDiscovery.findNavStatusService(serviceDiscoveryResponse) ?: run {
log.i("no navigation status channel - the cluster won't show turns")
return
}
val channel = navigation.serviceId
log.i("navigation status channel: $channel")
clusterNav = ClusterNav(channel) { id, body -> send(id, body, encrypted = true, channel = channel) }
clusterNav = ClusterNav(channel, navigation.minimumIntervalMs.toLong()) { id, body ->
send(id, body, encrypted = true, channel = channel)
}
openChannel(channel)
}
@@ -721,7 +725,7 @@ class GearslipRunner(
*/
private fun onNightSensor(body: ByteArray) {
val night = Wire.bytes(Wire.fields(body), SENSOR_NIGHT) ?: return
val isNight = Wire.varint(Wire.fields(night), 1) == 1L
val isNight = (Wire.varint(Wire.fields(night), 1) ?: 0L) != 0L
if (isNight != lastSensorNight) {
lastSensorNight = isNight
log.i("car says it's ${if (isNight) "night" else "day"}")
@@ -804,69 +808,60 @@ class GearslipRunner(
MSG_KEY_BINDING_RESPONSE ->
log.i("<- KeyBindingResponse: status=${Protobuf.readInt32Field(body, 1)}")
MSG_INPUT_REPORT -> {
val report = Wire.fields(body)
// The 2018 Uconnect agrees to Exit but keeps showing our last frame and sending
// touches, which looked like a crash. A touch means our screen is still up there.
if (videoStarted && !videoProjected && Wire.bytes(report, 3) != null) {
log.w("the car said it took its screen back but still sends touches - showing ours again")
onVideoFocus(VIDEO_FOCUS_PROJECTED)
}
Wire.bytes(report, 4)?.let(::onKeyEvent)
Wire.bytes(report, 6)?.let(::onRelativeEvent)
val touch = Wire.bytes(report, 3) ?: Wire.bytes(report, 7)
if (touch == null) {
return
}
val touchFields = Wire.fields(touch)
val action = Wire.varint(touchFields, 3)?.toInt() ?: 0
val pointers = Wire.allBytes(touchFields, 1)
if (pointers.isEmpty()) return
val config = videoConfigs.getOrNull(selectedConfigIndex ?: 0)
val size = config?.pixelSize()
// The car crops its margin off the frame and measures taps from the corner of what
// it shows, so they come in short by half the margin: the 2024 VW (margin 240) sent
// y=6, which is inside the cropped band, and the Uconnect's never passed 351 on a
// 480-high frame with a 130 margin. Without this every tap landed 120 px too high
// and the VW couldn't press past its first screen.
val shift = config?.let(::marginsFor) ?: Insets.NONE
// A stretched frame: the car's tap is on the frame, the UI is drawn smaller.
val drawn = drawSize
val points = pointers.mapIndexedNotNull { index, pointer ->
val fields = Wire.fields(pointer)
val rawX = Wire.varint(fields, 1)?.toInt() ?: return@mapIndexedNotNull null
val rawY = Wire.varint(fields, 2)?.toInt() ?: return@mapIndexedNotNull null
val id = Wire.varint(fields, 3)?.toInt() ?: index
if (drawn != null && size != null) {
TouchPoint(
id,
clamp(rawX * drawn.first / size.first, drawn.first),
clamp(rawY * drawn.second / size.second, drawn.second),
)
} else {
TouchPoint(id, clamp(rawX + shift.left, size?.first), clamp(rawY + shift.top, size?.second))
}
}
if (points.isEmpty()) return
val actionIndex = (Wire.varint(touchFields, 2)?.toInt() ?: 0).coerceIn(0, points.size - 1)
if (points.size > 1 && touchesSeen < 3) {
log.i("multi-touch: ${points.size} pointers, action=$action index=$actionIndex")
}
touchesSeen++
if (touchesSeen <= 10 || action == ACTION_DOWN || action == ACTION_UP) {
val at = points[actionIndex]
log.i("<- Touch action=$action at (${at.x.toInt()},${at.y.toInt()}) pointers=${points.size}")
}
// Time the next frame against this tap: see [reportTouchLatency].
if (action == ACTION_DOWN || action == ACTION_UP) touchAtUs = System.nanoTime() / 1000
projection?.onTouch(action, actionIndex, points)
}
MSG_INPUT_REPORT -> onInputReport(body)
else -> log.i("<- unhandled input message id=$messageId (${body.size} bytes)")
}
}
private fun onInputReport(body: ByteArray) {
val report = Wire.fields(body)
if (Wire.varint(report, 1) == null) return
Wire.bytes(report, 4)?.let(::onKeyEvent)
Wire.bytes(report, 6)?.let(::onRelativeEvent)
// Touchpads (field 7) need a separate gesture pipeline; their coordinates are not
// positions on the projected screen.
val touch = Wire.bytes(report, 3)
if (touch == null) {
return
}
// The 2018 Uconnect agrees to Exit but keeps showing our last frame and sending
// touches, which looked like a crash. A touch means our screen is still up there.
if (videoStarted && !videoProjected) {
log.w("the car said it took its screen back but still sends touches - showing ours again")
onVideoFocus(VIDEO_FOCUS_PROJECTED)
}
val config = videoConfigs.getOrNull(selectedConfigIndex ?: 0)
// The car crops its margin off the frame and measures taps from the corner of what it
// shows, so they come in short by half the margin: the 2024 VW (margin 240) sent y=6,
// inside the cropped band, and the Uconnect's never passed 351 on a 480-high frame with a
// 130 margin. Without this every tap landed 120 px too high.
val shift = config?.let(::marginsFor) ?: Insets.NONE
val size = config?.pixelSize()
// A stretched frame (see [screenShape]): the car's tap is on the frame, the UI is drawn smaller.
val drawn = drawSize
val stretched = drawn != null && size != null
val (action, actionIndex, raw) =
(if (stretched) InputProtocol.touch(touch, size) else InputProtocol.touch(touch, size, shift.left, shift.top)) ?: return
val points = if (drawn != null && size != null) {
raw.map { TouchPoint(it.id, it.x * drawn.first / size.first, it.y * drawn.second / size.second) }
} else {
raw
}
if (points.size > 1 && touchesSeen < 3) {
log.i("multi-touch: ${points.size} pointers, action=$action index=$actionIndex")
}
touchesSeen++
if (touchesSeen <= 10 || action == ACTION_DOWN || action == ACTION_UP) {
val at = points[actionIndex]
log.i("<- Touch action=$action at (${at.x.toInt()},${at.y.toInt()}) pointers=${points.size}")
}
// Time the next frame against this tap: see [reportTouchLatency].
if (action == ACTION_DOWN || action == ACTION_UP) touchAtUs = System.nanoTime() / 1000
projection?.onTouch(action, actionIndex, points)
}
/**
* Asks the head unit to forward its buttons. Many units send nothing from the steering wheel
* until the phone names the keys it wants. What the unit offered is asked for; a unit that
@@ -893,8 +888,9 @@ class GearslipRunner(
for (key in Wire.allBytes(Wire.fields(keyEvent), 1)) {
val fields = Wire.fields(key)
val keycode = Wire.varint(fields, 1)?.toInt() ?: continue
val down = Wire.varint(fields, 2) == 1L
val longPress = Wire.varint(fields, 4) == 1L
val down = (Wire.varint(fields, 2) ?: continue) != 0L
if (Wire.varint(fields, 3) == null) continue
val longPress = (Wire.varint(fields, 4) ?: 0L) != 0L
log.i("<- key event: keycode=$keycode ${if (down) "down" else "up"}${if (longPress) " long" else ""}")
if (!CarKeys.onKey(keycode, down, longPress) && down) log.i("unhandled keycode=$keycode")
}
@@ -910,6 +906,7 @@ class GearslipRunner(
val keycode = Wire.varint(fields, 1)?.toInt() ?: continue
// int32, so a turn the other way arrives as a 64-bit two's complement varint.
val delta = Wire.varint(fields, 2)?.toInt() ?: continue
if (keycode != 65536 || delta == 0) continue
log.i("<- relative event: keycode=$keycode delta=$delta")
CarKeys.onRotate(delta)
}
@@ -1260,13 +1257,6 @@ class GearslipRunner(
log.i("touch -> frame: ${draw + encode}ms (draw ${draw}ms, encode and wait ${encode}ms)")
}
private fun clamp(value: Int, limit: Int?): Float = when {
limit == null -> value.toFloat()
value < 0 -> 0f
value >= limit -> (limit - 1).toFloat()
else -> value.toFloat()
}
private fun onPingRequest(body: ByteArray) {
// The timestamp is an int64 and has to come back unchanged. Read as an int32 it was cut
// short, and a car that checks the echo (an Audi MIB2+) gave up at its second ping.
@@ -144,6 +144,26 @@ object Wire {
fun allBytes(fields: List<Field>, number: Int): List<ByteArray> =
fields.filter { it.number == number && it.wireType == 2 }.mapNotNull { it.bytes }
/** Protobuf readers accept both packed and unpacked repeated numeric fields. */
fun repeatedInts(fields: List<Field>, number: Int): List<Int> =
repeatedLongs(fields, number)?.map(Long::toInt).orEmpty()
/** Null means a packed field contained an incomplete or overflowing varint. */
fun repeatedLongs(fields: List<Field>, number: Int): List<Long>? = buildList {
for (field in fields.filter { it.number == number }) {
if (field.wireType == 0) add(field.varint)
else if (field.wireType == 2) {
val bytes = field.bytes ?: continue
var position = 0
while (position < bytes.size) {
val (value, next) = readVarint(bytes, position) ?: return null
add(value)
position = next
}
}
}
}
private fun readVarint(data: ByteArray, start: Int): Pair<Long, Int>? {
var result = 0L
var shift = 0
@@ -76,7 +76,7 @@ object ServiceDiscovery {
fun findAudioServices(response: ByteArray): List<AudioService> = buildList {
for (channel in Wire.allBytes(Wire.fields(response), 1)) {
val service = Wire.fields(channel)
val id = Wire.varint(service, 1)?.toInt() ?: continue
val id = serviceId(service) ?: continue
val sink = Wire.bytes(service, 3) ?: continue
val sinkFields = Wire.fields(sink)
val audioConfigs = Wire.allBytes(sinkFields, 3)
@@ -116,13 +116,26 @@ object ServiceDiscovery {
*/
fun findMediaStatusChannel(response: ByteArray): Int? = findChannelWith(response, 9)
/** The instrument cluster's turn-by-turn channel (NavigationStatusService, Service field 8). */
fun findNavStatusChannel(response: ByteArray): Int? = findChannelWith(response, 8)
class NavStatusService(val serviceId: Int, val minimumIntervalMs: Int)
/** The cluster advertises its minimum update interval in NavigationStatusService field 1. */
fun findNavStatusService(response: ByteArray): NavStatusService? =
Wire.allBytes(Wire.fields(response), 1).firstNotNullOfOrNull { channel ->
val service = Wire.fields(channel)
val id = serviceId(service) ?: return@firstNotNullOfOrNull null
val navigation = Wire.bytes(service, 8) ?: return@firstNotNullOfOrNull null
val interval = Wire.varint(Wire.fields(navigation), 1) ?: return@firstNotNullOfOrNull null
if (interval !in 0L..Int.MAX_VALUE.toLong()) return@firstNotNullOfOrNull null
NavStatusService(id, interval.toInt())
}
private fun serviceId(fields: List<Wire.Field>): Int? =
Wire.varint(fields, 1)?.takeIf { it in 1L..255L }?.toInt()
private fun findChannelWith(response: ByteArray, serviceField: Int): Int? =
Wire.allBytes(Wire.fields(response), 1).firstNotNullOfOrNull { channel ->
val service = Wire.fields(channel)
if (service.any { it.number == serviceField }) Wire.varint(service, 1)?.toInt() else null
if (Wire.bytes(service, serviceField) != null) serviceId(service) else null
}
/**
@@ -134,7 +147,7 @@ object ServiceDiscovery {
fun findMicService(response: ByteArray): AudioService? {
for (channel in Wire.allBytes(Wire.fields(response), 1)) {
val service = Wire.fields(channel)
val id = Wire.varint(service, 1)?.toInt() ?: continue
val id = serviceId(service) ?: continue
val source = Wire.fields(Wire.bytes(service, 5) ?: continue)
val f = Wire.fields(Wire.bytes(source, 2) ?: continue)
return AudioService(
@@ -179,9 +192,7 @@ object ServiceDiscovery {
val fields = Wire.fields(Wire.bytes(service, 6) ?: continue)
val address = Wire.bytes(fields, 1)?.let { String(it, Charsets.UTF_8) } ?: continue
// Packed as declared, though a car may send each method as its own varint.
val methods = fields.filter { it.number == 2 }.flatMap { f ->
f.bytes?.let(::packedVarints) ?: listOf(f.varint.toInt())
}
val methods = Wire.repeatedInts(fields, 2)
return BluetoothService(id, address, methods)
}
return null
@@ -215,8 +226,8 @@ object ServiceDiscovery {
* repeated TouchScreen touchscreen = 2; ... }
* TouchScreen { required int32 width = 1; required int32 height = 2; type = 3; }
*
* Touch coordinates arrive in this space (1258x708 on the 2018 Uconnect) and must be
* scaled to the projected video size before injection. [keycodesSupported] is what the
* Touch reports use projected video coordinates; these dimensions describe the input
* hardware and help select a video configuration. [keycodesSupported] is what the
* head unit says it will actually send over the Input channel as key events (steering-wheel
* buttons, etc.) - not every unit reports the same set, so it's kept for diagnostics and to
* know ahead of time whether a button like voice/PTT is even wired up on this hardware.
@@ -245,38 +256,18 @@ object ServiceDiscovery {
private fun inputServices(response: ByteArray): List<InputService> = buildList {
for (channel in Wire.allBytes(Wire.fields(response), 1)) {
val service = Wire.fields(channel)
val id = Wire.varint(service, 1)?.toInt() ?: continue
val id = serviceId(service) ?: continue
val input = Wire.bytes(service, 4) ?: continue
val inputFields = Wire.fields(input)
// A unit with only a knob and buttons has no touchscreen entry; it still has keys.
val touchFields = Wire.bytes(inputFields, 2)?.let { Wire.fields(it) }.orEmpty()
val width = Wire.varint(touchFields, 1)?.toInt() ?: 0
val height = Wire.varint(touchFields, 2)?.toInt() ?: 0
// Declared packed, but some units send each keycode as its own field.
val keycodes = inputFields.filter { it.number == 1 }.flatMap { f ->
f.bytes?.let(::packedVarints) ?: listOf(f.varint.toInt())
}
val keycodes = Wire.repeatedInts(inputFields, 1)
add(InputService(id, width, height, keycodes, Wire.varint(inputFields, 5)?.toInt() ?: 0))
}
}
/** Decodes a `[packed=true]` repeated varint field's raw bytes into individual values. */
private fun packedVarints(data: ByteArray): List<Int> = buildList {
var pos = 0
while (pos < data.size) {
var result = 0L
var shift = 0
while (pos < data.size) {
val b = data[pos].toInt() and 0xFF
result = result or ((b and 0x7F).toLong() shl shift)
pos++
if (b and 0x80 == 0) break
shift += 7
}
add(result.toInt())
}
}
/**
* 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.
@@ -289,7 +280,7 @@ object ServiceDiscovery {
private fun videoServices(response: ByteArray): List<VideoService> = buildList {
for (channel in Wire.allBytes(Wire.fields(response), 1)) {
val service = Wire.fields(channel)
val id = Wire.varint(service, 1)?.toInt() ?: continue
val id = serviceId(service) ?: continue
val sink = Wire.bytes(service, 3) ?: continue
val sinkFields = Wire.fields(sink)
@@ -316,16 +307,15 @@ object ServiceDiscovery {
* SensorSourceService { repeated Sensor sensors = 1; }
* Sensor { required SensorType sensor_type = 1; }
*
* Field numbers for what actually rides inside each SensorEvent once subscribed are not
* pinned down here - unlike video/audio/input, there's no independent source to check them
* against, so [CarSensors] shows the raw decode instead of trusting a guessed field map.
* SensorBatch contains repeated messages in fields 1 through 22 matching these type ids.
* The vehicle data screen keeps their raw contents for diagnostics.
*/
class SensorService(val serviceId: Int, val types: List<Int>)
fun findSensorService(response: ByteArray): SensorService? {
for (channel in Wire.allBytes(Wire.fields(response), 1)) {
val service = Wire.fields(channel)
val id = Wire.varint(service, 1)?.toInt() ?: continue
val id = serviceId(service) ?: continue
val sensorSource = Wire.bytes(service, 2) ?: continue
val types = Wire.allBytes(Wire.fields(sensorSource), 1).mapNotNull {
Wire.varint(Wire.fields(it), 1)?.toInt()
@@ -5,16 +5,7 @@ import app.seb3thehacker.gearslip.Wire
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
/**
* Everything the head unit's sensor channel has offered and sent, for the "Vehicle data" screen.
*
* [ServiceDiscovery.findSensorService] tells us which numeric sensor types this head unit is
* willing to stream; [SensorType] names the ones publicly documented by other AA reverse-engineering
* projects (aasdk, openauto). That mapping is carried over from other projects, not verified
* against this exact unit, so it's a label, not a guarantee - the raw field dump next to it is
* the ground truth. Once [onEvent] starts decoding real traffic from the 2018 Uconnect, correct
* any type that turns out to be mislabeled.
*/
/** Raw sensor readings, keyed by SensorBatch field number, which matches the sensor type id. */
object CarSensors {
data class Reading(val typeId: Int, val typeName: String, val raw: String, val updatedAtMs: Long)
@@ -30,16 +21,10 @@ object CarSensors {
}
}
/**
* One SensorEventIndication. Every reverse-engineered version of this message multiplexes
* every sensor type into one shared set of message-level field numbers (field N = "the data
* for sensor type N", roughly) rather than tagging events with the type id from
* SensorType.type - so field number is read here as a best-effort type id, same as the
* other services in this file.
*/
/** Each SensorBatch field contains repeated readings of one sensor type. */
fun onEvent(body: ByteArray) {
val now = System.currentTimeMillis()
val fields = Wire.fields(body)
val fields = Wire.fields(body).filter { it.wireType == 2 }
val byType = fields.groupBy { it.number }
val updated = byType.mapValues { (number, group) ->
Reading(
@@ -61,10 +46,7 @@ object CarSensors {
}
}
/**
* Sensor type ids as named across public Android Auto reverse-engineering references. Treated as
* a label, not a fact - see [CarSensors] doc.
*/
/** Sensor type values shared by the discovery service and SensorBatch. */
object SensorType {
private val NAMES = mapOf(
1 to "Location (GPS)",
@@ -89,6 +71,10 @@ object SensorType {
20 to "Gyroscope",
21 to "GPS satellites",
22 to "Toll card",
23 to "Vehicle energy model",
24 to "Trailer",
25 to "Raw vehicle energy model",
26 to "Raw EV trip settings",
)
fun name(id: Int): String = NAMES[id]?.let { "$it (#$id)" } ?: "Sensor #$id"
@@ -24,23 +24,36 @@ import app.seb3thehacker.gearslip.Protobuf
*/
class ClusterNav(
val channelId: Int,
minimumIntervalMs: Long = 0,
private val sendOnChannel: (messageId: Int, body: ByteArray) -> Unit,
) {
private val updateIntervalMs = maxOf(TICK_MS, minimumIntervalMs)
@Volatile private var running = false
private var worker: Thread? = null
private var updatesRequested = true
private var sentStatus = -1
private var sentTurn: String? = null
private var sentDistance: String? = null
@Synchronized
fun onMessage(messageId: Int, body: ByteArray) {
when (messageId) {
MSG_CHANNEL_OPEN_RESPONSE -> {
val status = Protobuf.readInt32Field(body, 1) ?: 0
val status = Protobuf.readInt32Field(body, 1)
GearslipLog.i("cluster: <- ChannelOpenResponse: status=$status")
if (status == 0) start()
}
// The car saying when it wants turns. Sending all along is harmless, so these are only logged.
MSG_START -> GearslipLog.i("cluster: <- start")
MSG_STOP -> GearslipLog.i("cluster: <- stop")
MSG_START -> {
updatesRequested = true
sentStatus = -1
sentTurn = null
sentDistance = null
GearslipLog.i("cluster: <- start")
}
MSG_STOP -> {
updatesRequested = false
GearslipLog.i("cluster: <- stop")
}
else -> GearslipLog.i("cluster: <- unhandled message id=$messageId (${body.size} bytes)")
}
}
@@ -48,19 +61,28 @@ class ClusterNav(
private fun start() {
if (running) return
running = true
Thread({
worker = Thread({
while (running) {
runCatching { tick() }.onFailure { GearslipLog.w("cluster: ${it.message}") }
Thread.sleep(TICK_MS)
try {
Thread.sleep(updateIntervalMs)
} catch (_: InterruptedException) {
return@Thread
}
}
}, "gearslip-cluster").start()
}, "gearslip-cluster").also { it.start() }
}
@Synchronized
fun stop() {
running = false
worker?.interrupt()
worker = null
}
@Synchronized
private fun tick() {
if (!running || !updatesRequested) return
val turn = NavTurn.current()
val status = when {
turn == null -> STATUS_INACTIVE
@@ -82,7 +104,7 @@ class ClusterNav(
val maneuver = step.maneuver
val (event, side) = maneuver?.let { eventFor(it.type) } ?: (EVENT_UNKNOWN to SIDE_UNSPECIFIED)
val road = step.road.text().ifBlank { step.cue.text() }
val key = "$road|$event|$side|${maneuver?.roundaboutExitNumber}"
val key = "$road|$event|$side|${maneuver?.roundaboutExitNumber}|${maneuver?.roundaboutExitAngle}"
if (key == sentTurn) return
sentTurn = key
var body = Protobuf.stringField(1, road) +
@@ -31,7 +31,7 @@ class CarMediaStatus(
fun onMessage(messageId: Int, body: ByteArray) {
when (messageId) {
MSG_CHANNEL_OPEN_RESPONSE -> {
val status = Protobuf.readInt32Field(body, 1) ?: 0
val status = Protobuf.readInt32Field(body, 1)
GearslipLog.i("media status: <- ChannelOpenResponse: status=$status")
if (status == 0) start()
}
@@ -0,0 +1,36 @@
package app.seb3thehacker.gearslip.protocol
import app.seb3thehacker.gearslip.TouchPoint
import app.seb3thehacker.gearslip.Wire
/** Decodes the touch coordinates and pointer index carried by an InputReport. */
internal object InputProtocol {
data class Touch(val action: Int, val actionIndex: Int, val points: List<TouchPoint>)
/** [shiftX] and [shiftY] move each point by the margin the car crops off before measuring. */
fun touch(body: ByteArray, size: Pair<Int, Int>?, shiftX: Int = 0, shiftY: Int = 0): Touch? {
val fields = Wire.fields(body)
val action = Wire.varint(fields, 3)?.toInt() ?: 0
if (action !in setOf(0, 1, 2, 5, 6)) return null
val pointers = Wire.allBytes(fields, 1)
if (pointers.isEmpty() || pointers.size > 32) return null
val actionIndex = Wire.varint(fields, 2)?.toInt() ?: 0
if (actionIndex !in pointers.indices) return null
val points = pointers.map { pointer ->
val values = Wire.fields(pointer)
val x = Wire.varint(values, 1)?.toInt() ?: return null
val y = Wire.varint(values, 2)?.toInt() ?: return null
val id = Wire.varint(values, 3) ?: return null
if (id !in 0L..31L) return null
TouchPoint(id.toInt(), clamp(x + shiftX, size?.first), clamp(y + shiftY, size?.second))
}
if (points.map { it.id }.toSet().size != points.size) return null
return Touch(action, actionIndex, points)
}
private fun clamp(value: Int, limit: Int?): Float = when {
limit == null -> value.toFloat()
limit <= 0 -> 0f
else -> value.coerceIn(0, limit - 1).toFloat()
}
}