diff --git a/gearslip/src/main/java/app/seb3thehacker/gearslip/GearslipRunner.kt b/gearslip/src/main/java/app/seb3thehacker/gearslip/GearslipRunner.kt index c219050..ecdedbd 100644 --- a/gearslip/src/main/java/app/seb3thehacker/gearslip/GearslipRunner.kt +++ b/gearslip/src/main/java/app/seb3thehacker/gearslip/GearslipRunner.kt @@ -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. diff --git a/gearslip/src/main/java/app/seb3thehacker/gearslip/Protobuf.kt b/gearslip/src/main/java/app/seb3thehacker/gearslip/Protobuf.kt index bf903da..61ea6bf 100644 --- a/gearslip/src/main/java/app/seb3thehacker/gearslip/Protobuf.kt +++ b/gearslip/src/main/java/app/seb3thehacker/gearslip/Protobuf.kt @@ -144,6 +144,26 @@ object Wire { fun allBytes(fields: List, number: Int): List = 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, number: Int): List = + repeatedLongs(fields, number)?.map(Long::toInt).orEmpty() + + /** Null means a packed field contained an incomplete or overflowing varint. */ + fun repeatedLongs(fields: List, number: Int): List? = 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? { var result = 0L var shift = 0 diff --git a/gearslip/src/main/java/app/seb3thehacker/gearslip/ServiceDiscovery.kt b/gearslip/src/main/java/app/seb3thehacker/gearslip/ServiceDiscovery.kt index b9f5266..8147a29 100644 --- a/gearslip/src/main/java/app/seb3thehacker/gearslip/ServiceDiscovery.kt +++ b/gearslip/src/main/java/app/seb3thehacker/gearslip/ServiceDiscovery.kt @@ -76,7 +76,7 @@ object ServiceDiscovery { fun findAudioServices(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 + 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): 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 = 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 = 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 = 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) 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() diff --git a/gearslip/src/main/java/app/seb3thehacker/gearslip/car/CarSensors.kt b/gearslip/src/main/java/app/seb3thehacker/gearslip/car/CarSensors.kt index 2b0e5cb..c36d263 100644 --- a/gearslip/src/main/java/app/seb3thehacker/gearslip/car/CarSensors.kt +++ b/gearslip/src/main/java/app/seb3thehacker/gearslip/car/CarSensors.kt @@ -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" diff --git a/gearslip/src/main/java/app/seb3thehacker/gearslip/car/ClusterNav.kt b/gearslip/src/main/java/app/seb3thehacker/gearslip/car/ClusterNav.kt index 8a4f8f6..ca91662 100644 --- a/gearslip/src/main/java/app/seb3thehacker/gearslip/car/ClusterNav.kt +++ b/gearslip/src/main/java/app/seb3thehacker/gearslip/car/ClusterNav.kt @@ -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) + diff --git a/gearslip/src/main/java/app/seb3thehacker/gearslip/media/CarMediaStatus.kt b/gearslip/src/main/java/app/seb3thehacker/gearslip/media/CarMediaStatus.kt index 0c1088a..417048b 100644 --- a/gearslip/src/main/java/app/seb3thehacker/gearslip/media/CarMediaStatus.kt +++ b/gearslip/src/main/java/app/seb3thehacker/gearslip/media/CarMediaStatus.kt @@ -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() } diff --git a/gearslip/src/main/java/app/seb3thehacker/gearslip/protocol/InputProtocol.kt b/gearslip/src/main/java/app/seb3thehacker/gearslip/protocol/InputProtocol.kt new file mode 100644 index 0000000..f9e219f --- /dev/null +++ b/gearslip/src/main/java/app/seb3thehacker/gearslip/protocol/InputProtocol.kt @@ -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) + + /** [shiftX] and [shiftY] move each point by the margin the car crops off before measuring. */ + fun touch(body: ByteArray, size: Pair?, 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() + } +} diff --git a/gearslip/src/test/java/app/seb3thehacker/gearslip/ConnectionLifecycleTest.kt b/gearslip/src/test/java/app/seb3thehacker/gearslip/ConnectionLifecycleTest.kt index db9b1a9..186cc68 100644 --- a/gearslip/src/test/java/app/seb3thehacker/gearslip/ConnectionLifecycleTest.kt +++ b/gearslip/src/test/java/app/seb3thehacker/gearslip/ConnectionLifecycleTest.kt @@ -1,5 +1,6 @@ package app.seb3thehacker.gearslip +import android.view.Surface import java.io.ByteArrayInputStream import java.io.ByteArrayOutputStream import java.io.IOException @@ -229,4 +230,27 @@ class ConnectionLifecycleTest { 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) { 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() + } + } + } diff --git a/gearslip/src/test/java/app/seb3thehacker/gearslip/ServiceDiscoveryChannelTest.kt b/gearslip/src/test/java/app/seb3thehacker/gearslip/ServiceDiscoveryChannelTest.kt new file mode 100644 index 0000000..ad3101b --- /dev/null +++ b/gearslip/src/test/java/app/seb3thehacker/gearslip/ServiceDiscoveryChannelTest.kt @@ -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)))) + } +} diff --git a/gearslip/src/test/java/app/seb3thehacker/gearslip/car/ClusterNavTest.kt b/gearslip/src/test/java/app/seb3thehacker/gearslip/car/ClusterNavTest.kt new file mode 100644 index 0000000..827993a --- /dev/null +++ b/gearslip/src/test/java/app/seb3thehacker/gearslip/car/ClusterNavTest.kt @@ -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() + 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)) + } +} diff --git a/gearslip/src/test/java/app/seb3thehacker/gearslip/protocol/InputProtocolTest.kt b/gearslip/src/test/java/app/seb3thehacker/gearslip/protocol/InputProtocolTest.kt new file mode 100644 index 0000000..6c91422 --- /dev/null +++ b/gearslip/src/test/java/app/seb3thehacker/gearslip/protocol/InputProtocolTest.kt @@ -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) + } +}