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
+335 -118

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.CarServices
import app.seb3thehacker.gearslip.car.CarSettings import app.seb3thehacker.gearslip.car.CarSettings
import java.io.IOException import java.io.IOException
import app.seb3thehacker.gearslip.protocol.InputProtocol
import java.io.InputStream import java.io.InputStream
import java.io.OutputStream import java.io.OutputStream
import java.util.concurrent.Executors 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. */ /** Turn-by-turn for the instrument cluster: the next turn, its road, and how far. */
private fun startClusterNavChannel(serviceDiscoveryResponse: ByteArray) { 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") log.i("no navigation status channel - the cluster won't show turns")
return return
} }
val channel = navigation.serviceId
log.i("navigation status channel: $channel") 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) openChannel(channel)
} }
@@ -721,7 +725,7 @@ class GearslipRunner(
*/ */
private fun onNightSensor(body: ByteArray) { private fun onNightSensor(body: ByteArray) {
val night = Wire.bytes(Wire.fields(body), SENSOR_NIGHT) ?: return 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) { if (isNight != lastSensorNight) {
lastSensorNight = isNight lastSensorNight = isNight
log.i("car says it's ${if (isNight) "night" else "day"}") log.i("car says it's ${if (isNight) "night" else "day"}")
@@ -804,51 +808,46 @@ class GearslipRunner(
MSG_KEY_BINDING_RESPONSE -> MSG_KEY_BINDING_RESPONSE ->
log.i("<- KeyBindingResponse: status=${Protobuf.readInt32Field(body, 1)}") log.i("<- KeyBindingResponse: status=${Protobuf.readInt32Field(body, 1)}")
MSG_INPUT_REPORT -> { MSG_INPUT_REPORT -> onInputReport(body)
val report = Wire.fields(body)
// The 2018 Uconnect agrees to Exit but keeps showing our last frame and sending else -> log.i("<- unhandled input message id=$messageId (${body.size} bytes)")
// 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)
} }
}
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, 4)?.let(::onKeyEvent)
Wire.bytes(report, 6)?.let(::onRelativeEvent) Wire.bytes(report, 6)?.let(::onRelativeEvent)
val touch = Wire.bytes(report, 3) ?: Wire.bytes(report, 7) // 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) { if (touch == null) {
return return
} }
val touchFields = Wire.fields(touch) // The 2018 Uconnect agrees to Exit but keeps showing our last frame and sending
val action = Wire.varint(touchFields, 3)?.toInt() ?: 0 // touches, which looked like a crash. A touch means our screen is still up there.
val pointers = Wire.allBytes(touchFields, 1) if (videoStarted && !videoProjected) {
if (pointers.isEmpty()) return 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) 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
// The car crops its margin off the frame and measures taps from the corner of what // shows, so they come in short by half the margin: the 2024 VW (margin 240) sent y=6,
// it shows, so they come in short by half the margin: the 2024 VW (margin 240) sent // inside the cropped band, and the Uconnect's never passed 351 on a 480-high frame with a
// y=6, which is inside the cropped band, and the Uconnect's never passed 351 on a // 130 margin. Without this every tap landed 120 px too high.
// 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 val shift = config?.let(::marginsFor) ?: Insets.NONE
// A stretched frame: the car's tap is on the frame, the UI is drawn smaller. 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 drawn = drawSize
val points = pointers.mapIndexedNotNull { index, pointer -> val stretched = drawn != null && size != null
val fields = Wire.fields(pointer) val (action, actionIndex, raw) =
val rawX = Wire.varint(fields, 1)?.toInt() ?: return@mapIndexedNotNull null (if (stretched) InputProtocol.touch(touch, size) else InputProtocol.touch(touch, size, shift.left, shift.top)) ?: return
val rawY = Wire.varint(fields, 2)?.toInt() ?: return@mapIndexedNotNull null val points = if (drawn != null && size != null) {
val id = Wire.varint(fields, 3)?.toInt() ?: index raw.map { TouchPoint(it.id, it.x * drawn.first / size.first, it.y * drawn.second / size.second) }
if (drawn != null && size != null) {
TouchPoint(
id,
clamp(rawX * drawn.first / size.first, drawn.first),
clamp(rawY * drawn.second / size.second, drawn.second),
)
} else { } else {
TouchPoint(id, clamp(rawX + shift.left, size?.first), clamp(rawY + shift.top, size?.second)) raw
} }
}
if (points.isEmpty()) return
val actionIndex = (Wire.varint(touchFields, 2)?.toInt() ?: 0).coerceIn(0, points.size - 1)
if (points.size > 1 && touchesSeen < 3) { if (points.size > 1 && touchesSeen < 3) {
log.i("multi-touch: ${points.size} pointers, action=$action index=$actionIndex") log.i("multi-touch: ${points.size} pointers, action=$action index=$actionIndex")
} }
@@ -863,10 +862,6 @@ class GearslipRunner(
projection?.onTouch(action, actionIndex, points) projection?.onTouch(action, actionIndex, points)
} }
else -> log.i("<- unhandled input message id=$messageId (${body.size} bytes)")
}
}
/** /**
* Asks the head unit to forward its buttons. Many units send nothing from the steering wheel * 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 * 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)) { for (key in Wire.allBytes(Wire.fields(keyEvent), 1)) {
val fields = Wire.fields(key) val fields = Wire.fields(key)
val keycode = Wire.varint(fields, 1)?.toInt() ?: continue val keycode = Wire.varint(fields, 1)?.toInt() ?: continue
val down = Wire.varint(fields, 2) == 1L val down = (Wire.varint(fields, 2) ?: continue) != 0L
val longPress = Wire.varint(fields, 4) == 1L 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 ""}") 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") 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 val keycode = Wire.varint(fields, 1)?.toInt() ?: continue
// int32, so a turn the other way arrives as a 64-bit two's complement varint. // int32, so a turn the other way arrives as a 64-bit two's complement varint.
val delta = Wire.varint(fields, 2)?.toInt() ?: continue val delta = Wire.varint(fields, 2)?.toInt() ?: continue
if (keycode != 65536 || delta == 0) continue
log.i("<- relative event: keycode=$keycode delta=$delta") log.i("<- relative event: keycode=$keycode delta=$delta")
CarKeys.onRotate(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)") 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) { private fun onPingRequest(body: ByteArray) {
// The timestamp is an int64 and has to come back unchanged. Read as an int32 it was cut // 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. // 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> = fun allBytes(fields: List<Field>, number: Int): List<ByteArray> =
fields.filter { it.number == number && it.wireType == 2 }.mapNotNull { it.bytes } 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>? { private fun readVarint(data: ByteArray, start: Int): Pair<Long, Int>? {
var result = 0L var result = 0L
var shift = 0 var shift = 0
@@ -76,7 +76,7 @@ object ServiceDiscovery {
fun findAudioServices(response: ByteArray): List<AudioService> = buildList { fun findAudioServices(response: ByteArray): List<AudioService> = buildList {
for (channel in Wire.allBytes(Wire.fields(response), 1)) { for (channel in Wire.allBytes(Wire.fields(response), 1)) {
val service = Wire.fields(channel) 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 sink = Wire.bytes(service, 3) ?: continue
val sinkFields = Wire.fields(sink) val sinkFields = Wire.fields(sink)
val audioConfigs = Wire.allBytes(sinkFields, 3) val audioConfigs = Wire.allBytes(sinkFields, 3)
@@ -116,13 +116,26 @@ object ServiceDiscovery {
*/ */
fun findMediaStatusChannel(response: ByteArray): Int? = findChannelWith(response, 9) fun findMediaStatusChannel(response: ByteArray): Int? = findChannelWith(response, 9)
/** The instrument cluster's turn-by-turn channel (NavigationStatusService, Service field 8). */ class NavStatusService(val serviceId: Int, val minimumIntervalMs: Int)
fun findNavStatusChannel(response: ByteArray): Int? = findChannelWith(response, 8)
/** 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? = private fun findChannelWith(response: ByteArray, serviceField: Int): Int? =
Wire.allBytes(Wire.fields(response), 1).firstNotNullOfOrNull { channel -> Wire.allBytes(Wire.fields(response), 1).firstNotNullOfOrNull { channel ->
val service = Wire.fields(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? { fun findMicService(response: ByteArray): AudioService? {
for (channel in Wire.allBytes(Wire.fields(response), 1)) { for (channel in Wire.allBytes(Wire.fields(response), 1)) {
val service = Wire.fields(channel) 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 source = Wire.fields(Wire.bytes(service, 5) ?: continue)
val f = Wire.fields(Wire.bytes(source, 2) ?: continue) val f = Wire.fields(Wire.bytes(source, 2) ?: continue)
return AudioService( return AudioService(
@@ -179,9 +192,7 @@ object ServiceDiscovery {
val fields = Wire.fields(Wire.bytes(service, 6) ?: continue) val fields = Wire.fields(Wire.bytes(service, 6) ?: continue)
val address = Wire.bytes(fields, 1)?.let { String(it, Charsets.UTF_8) } ?: 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. // Packed as declared, though a car may send each method as its own varint.
val methods = fields.filter { it.number == 2 }.flatMap { f -> val methods = Wire.repeatedInts(fields, 2)
f.bytes?.let(::packedVarints) ?: listOf(f.varint.toInt())
}
return BluetoothService(id, address, methods) return BluetoothService(id, address, methods)
} }
return null return null
@@ -215,8 +226,8 @@ object ServiceDiscovery {
* repeated TouchScreen touchscreen = 2; ... } * repeated TouchScreen touchscreen = 2; ... }
* TouchScreen { required int32 width = 1; required int32 height = 2; type = 3; } * 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 * Touch reports use projected video coordinates; these dimensions describe the input
* scaled to the projected video size before injection. [keycodesSupported] is what the * 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 * 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 * 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. * 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 { private fun inputServices(response: ByteArray): List<InputService> = buildList {
for (channel in Wire.allBytes(Wire.fields(response), 1)) { for (channel in Wire.allBytes(Wire.fields(response), 1)) {
val service = Wire.fields(channel) 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 input = Wire.bytes(service, 4) ?: continue
val inputFields = Wire.fields(input) val inputFields = Wire.fields(input)
// A unit with only a knob and buttons has no touchscreen entry; it still has keys. // 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 touchFields = Wire.bytes(inputFields, 2)?.let { Wire.fields(it) }.orEmpty()
val width = Wire.varint(touchFields, 1)?.toInt() ?: 0 val width = Wire.varint(touchFields, 1)?.toInt() ?: 0
val height = Wire.varint(touchFields, 2)?.toInt() ?: 0 val height = Wire.varint(touchFields, 2)?.toInt() ?: 0
// Declared packed, but some units send each keycode as its own field. val keycodes = Wire.repeatedInts(inputFields, 1)
val keycodes = inputFields.filter { it.number == 1 }.flatMap { f ->
f.bytes?.let(::packedVarints) ?: listOf(f.varint.toInt())
}
add(InputService(id, width, height, keycodes, Wire.varint(inputFields, 5)?.toInt() ?: 0)) 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 * 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. * 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 { private fun videoServices(response: ByteArray): List<VideoService> = buildList {
for (channel in Wire.allBytes(Wire.fields(response), 1)) { for (channel in Wire.allBytes(Wire.fields(response), 1)) {
val service = Wire.fields(channel) 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 sink = Wire.bytes(service, 3) ?: continue
val sinkFields = Wire.fields(sink) val sinkFields = Wire.fields(sink)
@@ -316,16 +307,15 @@ object ServiceDiscovery {
* SensorSourceService { repeated Sensor sensors = 1; } * SensorSourceService { repeated Sensor sensors = 1; }
* Sensor { required SensorType sensor_type = 1; } * Sensor { required SensorType sensor_type = 1; }
* *
* Field numbers for what actually rides inside each SensorEvent once subscribed are not * SensorBatch contains repeated messages in fields 1 through 22 matching these type ids.
* pinned down here - unlike video/audio/input, there's no independent source to check them * The vehicle data screen keeps their raw contents for diagnostics.
* against, so [CarSensors] shows the raw decode instead of trusting a guessed field map.
*/ */
class SensorService(val serviceId: Int, val types: List<Int>) class SensorService(val serviceId: Int, val types: List<Int>)
fun findSensorService(response: ByteArray): SensorService? { fun findSensorService(response: ByteArray): SensorService? {
for (channel in Wire.allBytes(Wire.fields(response), 1)) { for (channel in Wire.allBytes(Wire.fields(response), 1)) {
val service = Wire.fields(channel) 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 sensorSource = Wire.bytes(service, 2) ?: continue
val types = Wire.allBytes(Wire.fields(sensorSource), 1).mapNotNull { val types = Wire.allBytes(Wire.fields(sensorSource), 1).mapNotNull {
Wire.varint(Wire.fields(it), 1)?.toInt() 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.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.StateFlow
/** /** Raw sensor readings, keyed by SensorBatch field number, which matches the sensor type id. */
* 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.
*/
object CarSensors { object CarSensors {
data class Reading(val typeId: Int, val typeName: String, val raw: String, val updatedAtMs: Long) data class Reading(val typeId: Int, val typeName: String, val raw: String, val updatedAtMs: Long)
@@ -30,16 +21,10 @@ object CarSensors {
} }
} }
/** /** Each SensorBatch field contains repeated readings of one sensor type. */
* 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.
*/
fun onEvent(body: ByteArray) { fun onEvent(body: ByteArray) {
val now = System.currentTimeMillis() 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 byType = fields.groupBy { it.number }
val updated = byType.mapValues { (number, group) -> val updated = byType.mapValues { (number, group) ->
Reading( Reading(
@@ -61,10 +46,7 @@ object CarSensors {
} }
} }
/** /** Sensor type values shared by the discovery service and SensorBatch. */
* Sensor type ids as named across public Android Auto reverse-engineering references. Treated as
* a label, not a fact - see [CarSensors] doc.
*/
object SensorType { object SensorType {
private val NAMES = mapOf( private val NAMES = mapOf(
1 to "Location (GPS)", 1 to "Location (GPS)",
@@ -89,6 +71,10 @@ object SensorType {
20 to "Gyroscope", 20 to "Gyroscope",
21 to "GPS satellites", 21 to "GPS satellites",
22 to "Toll card", 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" fun name(id: Int): String = NAMES[id]?.let { "$it (#$id)" } ?: "Sensor #$id"
@@ -24,23 +24,36 @@ import app.seb3thehacker.gearslip.Protobuf
*/ */
class ClusterNav( class ClusterNav(
val channelId: Int, val channelId: Int,
minimumIntervalMs: Long = 0,
private val sendOnChannel: (messageId: Int, body: ByteArray) -> Unit, private val sendOnChannel: (messageId: Int, body: ByteArray) -> Unit,
) { ) {
private val updateIntervalMs = maxOf(TICK_MS, minimumIntervalMs)
@Volatile private var running = false @Volatile private var running = false
private var worker: Thread? = null
private var updatesRequested = true
private var sentStatus = -1 private var sentStatus = -1
private var sentTurn: String? = null private var sentTurn: String? = null
private var sentDistance: String? = null private var sentDistance: String? = null
@Synchronized
fun onMessage(messageId: Int, body: ByteArray) { fun onMessage(messageId: Int, body: ByteArray) {
when (messageId) { when (messageId) {
MSG_CHANNEL_OPEN_RESPONSE -> { MSG_CHANNEL_OPEN_RESPONSE -> {
val status = Protobuf.readInt32Field(body, 1) ?: 0 val status = Protobuf.readInt32Field(body, 1)
GearslipLog.i("cluster: <- ChannelOpenResponse: status=$status") GearslipLog.i("cluster: <- ChannelOpenResponse: status=$status")
if (status == 0) start() if (status == 0) start()
} }
// The car saying when it wants turns. Sending all along is harmless, so these are only logged. MSG_START -> {
MSG_START -> GearslipLog.i("cluster: <- start") updatesRequested = true
MSG_STOP -> GearslipLog.i("cluster: <- stop") 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)") else -> GearslipLog.i("cluster: <- unhandled message id=$messageId (${body.size} bytes)")
} }
} }
@@ -48,19 +61,28 @@ class ClusterNav(
private fun start() { private fun start() {
if (running) return if (running) return
running = true running = true
Thread({ worker = Thread({
while (running) { while (running) {
runCatching { tick() }.onFailure { GearslipLog.w("cluster: ${it.message}") } 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() { fun stop() {
running = false running = false
worker?.interrupt()
worker = null
} }
@Synchronized
private fun tick() { private fun tick() {
if (!running || !updatesRequested) return
val turn = NavTurn.current() val turn = NavTurn.current()
val status = when { val status = when {
turn == null -> STATUS_INACTIVE turn == null -> STATUS_INACTIVE
@@ -82,7 +104,7 @@ class ClusterNav(
val maneuver = step.maneuver val maneuver = step.maneuver
val (event, side) = maneuver?.let { eventFor(it.type) } ?: (EVENT_UNKNOWN to SIDE_UNSPECIFIED) val (event, side) = maneuver?.let { eventFor(it.type) } ?: (EVENT_UNKNOWN to SIDE_UNSPECIFIED)
val road = step.road.text().ifBlank { step.cue.text() } 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 if (key == sentTurn) return
sentTurn = key sentTurn = key
var body = Protobuf.stringField(1, road) + var body = Protobuf.stringField(1, road) +
@@ -31,7 +31,7 @@ class CarMediaStatus(
fun onMessage(messageId: Int, body: ByteArray) { fun onMessage(messageId: Int, body: ByteArray) {
when (messageId) { when (messageId) {
MSG_CHANNEL_OPEN_RESPONSE -> { MSG_CHANNEL_OPEN_RESPONSE -> {
val status = Protobuf.readInt32Field(body, 1) ?: 0 val status = Protobuf.readInt32Field(body, 1)
GearslipLog.i("media status: <- ChannelOpenResponse: status=$status") GearslipLog.i("media status: <- ChannelOpenResponse: status=$status")
if (status == 0) start() 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()
}
}
@@ -1,5 +1,6 @@
package app.seb3thehacker.gearslip package app.seb3thehacker.gearslip
import android.view.Surface
import java.io.ByteArrayInputStream import java.io.ByteArrayInputStream
import java.io.ByteArrayOutputStream import java.io.ByteArrayOutputStream
import java.io.IOException import java.io.IOException
@@ -229,4 +230,27 @@ class ConnectionLifecycleTest {
GearslipRunner::class.java.getDeclaredField(name).apply { isAccessible = true }.set(runner, value) GearslipRunner::class.java.getDeclaredField(name).apply { isAccessible = true }.set(runner, value)
} }
@Test fun `touchpad reports are not injected as touchscreen taps`() {
var touches = 0
val projection = object : Projection {
override fun onSurfaceReady(surface: Surface, width: Int, height: Int, densityDpi: Int) = Unit
override fun onTouch(action: Int, actionIndex: Int, points: List<TouchPoint>) { touches++ }
override fun onProjectionStopped() = Unit
}
val runner = GearslipRunner(ByteArrayInputStream(byteArrayOf()), ByteArrayOutputStream(), ::identity, projection)
val input = GearslipRunner::class.java.getDeclaredMethod("onInputReport", ByteArray::class.java)
.apply { isAccessible = true }
val touch = Protobuf.bytesField(1, Protobuf.varintField(1, 100) +
Protobuf.varintField(2, 200) + Protobuf.varintField(3, 0))
try {
input.invoke(runner, Protobuf.varintField(1, 1000) + Protobuf.bytesField(7, touch))
assertEquals(0, touches)
input.invoke(runner, Protobuf.varintField(1, 1000) + Protobuf.bytesField(3, touch))
assertEquals(1, touches)
} finally {
runner.stop()
}
}
} }
@@ -0,0 +1,36 @@
package app.seb3thehacker.gearslip
import org.junit.Assert.assertEquals
import org.junit.Assert.assertNull
import org.junit.Assert.assertTrue
import org.junit.Test
class ServiceDiscoveryChannelTest {
private fun service(id: Long, field: Int, payload: ByteArray = byteArrayOf()) =
Protobuf.bytesField(1, Protobuf.varintField(1, id) + Protobuf.bytesField(field, payload))
@Test fun `invalid channel IDs cannot alias the control or another service channel`() {
val config = Protobuf.varintField(1, 16000) + Protobuf.varintField(2, 16) + Protobuf.varintField(3, 1)
val audio = Protobuf.bytesField(3, config)
val microphone = Protobuf.bytesField(2, config)
val sensor = Protobuf.bytesField(1, Protobuf.varintField(1, 1))
for (id in listOf(-1L, 0L, 256L, 0x1_0000_0001L)) {
assertNull(ServiceDiscovery.findMediaStatusChannel(service(id, 9)))
assertNull(ServiceDiscovery.findNavStatusService(service(id, 8, Protobuf.varintField(1, 1000))))
assertNull(ServiceDiscovery.findVideoService(service(id, 3, Protobuf.bytesField(4, byteArrayOf()))))
assertNull(ServiceDiscovery.findInputService(service(id, 4)))
assertNull(ServiceDiscovery.findMicService(service(id, 5, microphone)))
assertNull(ServiceDiscovery.findSensorService(service(id, 2, sensor)))
assertTrue(ServiceDiscovery.findAudioServices(service(id, 3, audio)).isEmpty())
}
assertEquals(255, ServiceDiscovery.findMediaStatusChannel(service(255, 9)))
}
@Test fun `navigation discovery preserves the requested update interval`() {
val navigation = requireNotNull(ServiceDiscovery.findNavStatusService(service(6, 8, Protobuf.varintField(1, 1500))))
assertEquals(6, navigation.serviceId)
assertEquals(1500, navigation.minimumIntervalMs)
assertNull(ServiceDiscovery.findNavStatusService(service(6, 8)))
assertNull(ServiceDiscovery.findNavStatusService(service(6, 8, Protobuf.varintField(1, -1))))
}
}
@@ -0,0 +1,61 @@
package app.seb3thehacker.gearslip.car
import app.seb3thehacker.gearslip.Protobuf
import java.util.concurrent.CountDownLatch
import java.util.concurrent.TimeUnit
import java.util.concurrent.atomic.AtomicInteger
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertTrue
import org.junit.Test
class ClusterNavTest {
@Test fun `head unit minimum interval limits repeated navigation updates`() {
val first = CountDownLatch(1)
val next = CountDownLatch(1)
val count = AtomicInteger()
val nav = ClusterNav(6, 1000) { _, _ ->
if (count.incrementAndGet() == 1) first.countDown() else next.countDown()
}
try {
nav.onMessage(8, Protobuf.varintField(1, 0))
assertTrue(first.await(2, TimeUnit.SECONDS))
nav.onMessage(32769, byteArrayOf())
assertFalse("the fixed 500ms timer must not override the head unit", next.await(700, TimeUnit.MILLISECONDS))
assertTrue(next.await(2, TimeUnit.SECONDS))
} finally {
nav.stop()
}
}
@Test fun `head unit stop pauses updates and start resends current status`() {
val messages = mutableListOf<Int>()
val nav = ClusterNav(6) { id, _ -> messages += id }
// Drive polling synchronously so the test does not depend on a background timer.
val running = ClusterNav::class.java.getDeclaredField("running").apply { isAccessible = true }
val tick = ClusterNav::class.java.getDeclaredMethod("tick").apply { isAccessible = true }
running.set(nav, true)
try {
tick.invoke(nav)
assertEquals(listOf(32771), messages)
nav.onMessage(32770, byteArrayOf())
tick.invoke(nav)
assertEquals(1, messages.size)
nav.onMessage(32769, byteArrayOf())
tick.invoke(nav)
assertEquals(listOf(32771, 32771), messages)
nav.stop()
tick.invoke(nav)
assertEquals(2, messages.size)
} finally {
nav.stop()
}
}
@Test fun `missing channel open status does not start publishing`() {
val nav = ClusterNav(6) { _, _ -> error("channel was not accepted") }
nav.onMessage(8, byteArrayOf())
val running = ClusterNav::class.java.getDeclaredField("running").apply { isAccessible = true }
assertFalse(running.getBoolean(nav))
}
}
@@ -0,0 +1,52 @@
package app.seb3thehacker.gearslip.protocol
import app.seb3thehacker.gearslip.Protobuf
import org.junit.Assert.assertEquals
import org.junit.Assert.assertNull
import org.junit.Test
class InputProtocolTest {
private fun pointer(id: Int) = Protobuf.bytesField(1,
Protobuf.varintField(1, 100) + Protobuf.varintField(2, 200) + Protobuf.varintField(3, id.toLong()))
@Test fun `multitouch preserves pointer IDs and action index`() {
val body = pointer(7) + pointer(3) + Protobuf.varintField(2, 1) + Protobuf.varintField(3, 5)
val touch = requireNotNull(InputProtocol.touch(body, 800 to 480))
assertEquals(5, touch.action)
assertEquals(1, touch.actionIndex)
assertEquals(listOf(7, 3), touch.points.map { it.id })
assertEquals(100f, touch.points[1].x)
}
@Test fun `invalid pointer index is rejected instead of targeting a different finger`() {
assertNull(InputProtocol.touch(pointer(0) + Protobuf.varintField(2, 1), null))
assertNull(InputProtocol.touch(pointer(0) + Protobuf.varintField(2, -1), null))
}
@Test fun `missing required pointer fields and duplicate IDs are rejected`() {
assertNull(InputProtocol.touch(Protobuf.bytesField(1, Protobuf.varintField(1, 20)), null))
assertNull(InputProtocol.touch(pointer(1) + pointer(1), null))
}
@Test fun `pointer IDs must fit the Android motion event range`() {
for (id in listOf(-1L, 32L, 0x1_0000_0000L)) {
val body = Protobuf.bytesField(1, Protobuf.varintField(1, 100) +
Protobuf.varintField(2, 200) + Protobuf.varintField(3, id))
assertNull(InputProtocol.touch(body, null))
}
assertEquals(31, requireNotNull(InputProtocol.touch(pointer(31), null)).points.single().id)
}
@Test fun `oversized pointer sets are rejected before constructing motion events`() {
val body = (0..32).fold(byteArrayOf()) { bytes, id -> bytes + pointer(id) }
assertNull(InputProtocol.touch(body, null))
}
@Test fun `optional touch fields use down and first pointer defaults`() {
val touch = requireNotNull(InputProtocol.touch(pointer(0), 50 to 60))
assertEquals(0, touch.action)
assertEquals(0, touch.actionIndex)
assertEquals(49f, touch.points.single().x)
assertEquals(59f, touch.points.single().y)
}
}