From 7d197bf021d93a900243b5ddd3cc941924f5fd30 Mon Sep 17 00:00:00 2001 From: Seb3thehacker Date: Mon, 5 Oct 2026 01:21:16 -0700 Subject: [PATCH] fix silent audio on cars with an AAC sink, keep location out of logs Cars like the 2022 Dacia Jogger list a compressed audio channel before the raw one, and Gearslip was sending raw audio to it. The Exit tile no longer shows as running, and the log leaves out the weather town and the car's GPS. --- .../seb3thehacker/gearslip/GearslipRunner.kt | 14 ++++++-- .../gearslip/ServiceDiscovery.kt | 14 ++++++++ .../app/seb3thehacker/gearslip/car/NavApps.kt | 11 +++--- .../app/seb3thehacker/gearslip/car/Weather.kt | 6 ++-- .../gearslip/ServiceDiscoveryAudioTest.kt | 35 +++++++++++++++++++ 5 files changed, 72 insertions(+), 8 deletions(-) diff --git a/gearslip/src/main/java/app/seb3thehacker/gearslip/GearslipRunner.kt b/gearslip/src/main/java/app/seb3thehacker/gearslip/GearslipRunner.kt index d6914ad..442fb79 100644 --- a/gearslip/src/main/java/app/seb3thehacker/gearslip/GearslipRunner.kt +++ b/gearslip/src/main/java/app/seb3thehacker/gearslip/GearslipRunner.kt @@ -357,6 +357,7 @@ class GearslipRunner( val info = ServiceDiscovery.findHeadUnitInfo(body) log.i("head unit: $info") SessionReport.headUnit(info.toString()) + SessionReport.headUnitInfo = info vehicleProfile = runCatching { vehicleProfileFor(info) }.getOrNull() val profile = vehicleProfile SessionReport.profile(profile?.name) @@ -430,13 +431,16 @@ class GearslipRunner( sink.configs.joinToString(prefix = "[", postfix = "]") { "${it.sampleRate}Hz/${it.bits}bit/x${it.channels}" }, ) } - val media = sinks.firstOrNull { it.streamType == STREAM_MEDIA } ?: run { + val media = ServiceDiscovery.pickMediaSink(sinks) ?: run { log.w("no media audio sink advertised - media apps will play on the phone only") SessionReport.audio( "no MEDIA sink; head unit offered ${sinks.joinToString { it.streamName }.ifEmpty { "none" }}", ) return } + if (media.codecType != ServiceDiscovery.CODEC_PCM) { + log.w("the car offers no PCM media sink, only ${media.codecName}; it may play our PCM as silence") + } val first = media.configs.firstOrNull() SessionReport.audio( "MEDIA sink on channel ${media.serviceId}, ${media.codecName}, " + @@ -481,7 +485,11 @@ class GearslipRunner( private fun logSensorEvent(body: ByteArray) { val types = Wire.fields(body).map { it.number }.toSet() - if (sensorTypesLogged.addAll(types)) log.i("<- SensorEvent (first of its kind):\n" + Protobuf.describe(body)) + if (sensorTypesLogged.addAll(types)) { + // Type 1 is the car's GPS position; logs get shared, so say it arrived and nothing more. + if (SENSOR_LOCATION in types) log.i("<- SensorEvent (first of its kind): types=$types, location left out") + else log.i("<- SensorEvent (first of its kind):\n" + Protobuf.describe(body)) + } sensorEventsSinceLog++ val now = System.currentTimeMillis() if (sensorLoggedAt == 0L) sensorLoggedAt = now @@ -763,6 +771,7 @@ class GearslipRunner( log.e("unsupported resolution ${config.resolutionName}") return } + config.visibleSize()?.let { (w, h) -> SessionReport.carScreen("${w}x$h", config.density) } val start = Protobuf.varintField(1, SESSION_ID.toLong()) + Protobuf.varintField(2, index.toLong()) @@ -1127,6 +1136,7 @@ class GearslipRunner( const val MSG_SENSOR_START_RESPONSE = 32770 const val MSG_SENSOR_EVENT_INDICATION = 32771 const val MSG_SENSOR_ERROR = 32772 + private const val SENSOR_LOCATION = 1 private const val SENSOR_LOG_EVERY_MS = 10_000L const val STREAM_MEDIA = 3 diff --git a/gearslip/src/main/java/app/seb3thehacker/gearslip/ServiceDiscovery.kt b/gearslip/src/main/java/app/seb3thehacker/gearslip/ServiceDiscovery.kt index b5fcab2..9cf5361 100644 --- a/gearslip/src/main/java/app/seb3thehacker/gearslip/ServiceDiscovery.kt +++ b/gearslip/src/main/java/app/seb3thehacker/gearslip/ServiceDiscovery.kt @@ -99,6 +99,20 @@ object ServiceDiscovery { } } + /** + * The media sink to stream to. Gearslip sends raw PCM, so a PCM sink wins: the 2022 Dacia + * Jogger (LGE ULC 4.5) lists an AAC media sink on channel 3 before a PCM one on channel 5, + * and PCM pushed into the AAC sink was acked but played as silence. With no PCM sink, the + * first media sink is the best there is. + */ + fun pickMediaSink(sinks: List): AudioService? { + val media = sinks.filter { it.streamType == STREAM_MEDIA } + return media.firstOrNull { it.codecType == CODEC_PCM } ?: media.firstOrNull() + } + + const val STREAM_MEDIA = 3 + const val CODEC_PCM = 1 + private val STREAMS = mapOf(0 to "NONE", 1 to "GUIDANCE", 2 to "SYSTEM_AUDIO", 3 to "MEDIA", 4 to "TELEPHONY") private val RESOLUTIONS = mapOf( diff --git a/gearslip/src/main/java/app/seb3thehacker/gearslip/car/NavApps.kt b/gearslip/src/main/java/app/seb3thehacker/gearslip/car/NavApps.kt index 139d044..ce706fe 100644 --- a/gearslip/src/main/java/app/seb3thehacker/gearslip/car/NavApps.kt +++ b/gearslip/src/main/java/app/seb3thehacker/gearslip/car/NavApps.kt @@ -47,7 +47,6 @@ import androidx.compose.ui.graphics.vector.PathParser /** What a built-in app's id carries in a pin, so it can't collide with an installed app's component. */ internal const val BUILT_IN_PREFIX = "gearslip:" -/** One of Gearslip's own apps: a screen, with nothing running behind it once you leave. */ /** Material's "directions car", drawn here because only the core icon set is bundled. */ private val CarGlyph: ImageVector = ImageVector.Builder("Car", 24.dp, 24.dp, 24f, 24f).addPath( PathParser().parsePathString( @@ -60,11 +59,15 @@ private val CarGlyph: ImageVector = ImageVector.Builder("Car", 24.dp, 24.dp, 24f fill = SolidColor(Color.Black), ).build() +/** + * One of Gearslip's own apps: a screen, with nothing running behind it once you leave. [screen] is + * null for an action like Exit, which has no screen and so never shows as open. + */ internal class BuiltInApp( val id: String, val label: String, val glyph: ImageVector, - val screen: CarScreen, + val screen: CarScreen?, val open: (CarNavigator) -> Unit, ) @@ -76,7 +79,7 @@ internal object BuiltInApps { val Settings = BuiltInApp("settings", "Settings", Icons.Filled.Settings, CarScreen.Settings) { it.settings() } /** Hands the screen back to the car's own interface, like Android Auto's Exit. */ - val Exit = BuiltInApp("exit", "Exit", CarGlyph, CarScreen.Home) { CarFocus.exitToCar() } + val Exit = BuiltInApp("exit", "Exit", CarGlyph, screen = null) { CarFocus.exitToCar() } val all = listOf(Web, ScreenSharing, Phone, VehicleData, Settings, Exit) val entries: List = all.map { Entry(it.label, icon = null, builtIn = it) } @@ -128,7 +131,7 @@ internal fun rememberRunningApps(): Map { else -> false } mediaApp?.component?.flattenToString()?.takeIf { mediaLive }?.let { put(it, RunningSlot.MEDIA) } - BuiltInApps.all.firstOrNull { it.screen == current }?.let { put(BUILT_IN_PREFIX + it.id, RunningSlot.SCREEN) } + BuiltInApps.all.firstOrNull { it.screen != null && it.screen == current }?.let { put(BUILT_IN_PREFIX + it.id, RunningSlot.SCREEN) } (current as? CarScreen.Messages)?.let { put(MESSAGING_PREFIX + it.packageName, RunningSlot.SCREEN) } } } diff --git a/gearslip/src/main/java/app/seb3thehacker/gearslip/car/Weather.kt b/gearslip/src/main/java/app/seb3thehacker/gearslip/car/Weather.kt index 5c9ea2d..976ec17 100644 --- a/gearslip/src/main/java/app/seb3thehacker/gearslip/car/Weather.kt +++ b/gearslip/src/main/java/app/seb3thehacker/gearslip/car/Weather.kt @@ -84,11 +84,13 @@ object Weather { return } val data = runCatching { fetch(context, location) } - .onFailure { GearslipLog.w("weather fetch failed: ${it.message}") } + // Only the kind of error: some messages carry the request URL, and that holds the location. + .onFailure { GearslipLog.w("weather fetch failed: ${it.javaClass.simpleName}") } .getOrNull() if (data != null) { flow.value = WeatherState.Ready(data) - GearslipLog.i("weather: ${data.place} ${data.temp}${data.tempUnit} ${describe(data.code)}") + // No place name: logs get shared in bug reports. + GearslipLog.i("weather: loaded") } else if (flow.value !is WeatherState.Ready) flow.value = WeatherState.Unavailable("Could not reach the weather service.") } diff --git a/gearslip/src/test/java/app/seb3thehacker/gearslip/ServiceDiscoveryAudioTest.kt b/gearslip/src/test/java/app/seb3thehacker/gearslip/ServiceDiscoveryAudioTest.kt index 15a180a..8696874 100644 --- a/gearslip/src/test/java/app/seb3thehacker/gearslip/ServiceDiscoveryAudioTest.kt +++ b/gearslip/src/test/java/app/seb3thehacker/gearslip/ServiceDiscoveryAudioTest.kt @@ -2,6 +2,7 @@ package app.seb3thehacker.gearslip import org.junit.Assert.assertEquals import org.junit.Assert.assertNotNull +import org.junit.Assert.assertNull import org.junit.Test /** @@ -12,6 +13,10 @@ import org.junit.Test * video sink on channel 1 and a microphone source on channel 3. Picking the right one out of that * set is the whole job of [ServiceDiscovery.findAudioServices], and getting it wrong is silent: * audio simply never arrives. + * + * The 2022 Dacia Jogger (LGE ULC 4.5, protocol 1.5) offers four: AAC media on channel 3, AAC + * guidance on 4, PCM media on 5 and PCM guidance on 6. Gearslip sends PCM, so it has to pick + * channel 5 even though channel 3 comes first; PCM pushed into the AAC sink plays as silence. */ class ServiceDiscoveryAudioTest { @@ -91,4 +96,34 @@ class ServiceDiscoveryAudioTest { assertNotNull(ServiceDiscovery.findVideoService(uconnectResponse())) assertEquals(1, ServiceDiscovery.findVideoService(uconnectResponse())!!.serviceId) } + + private fun daciaResponse(): ByteArray = + audioSink(id = 3, codec = 4, audioType = 3, config = audioConfig(48_000, 16, 2)) + + audioSink(id = 4, codec = 4, audioType = 1, config = audioConfig(16_000, 16, 1)) + + audioSink(id = 5, codec = 1, audioType = 3, config = audioConfig(48_000, 16, 2)) + + audioSink(id = 6, codec = 1, audioType = 1, config = audioConfig(16_000, 16, 1)) + + @Test + fun `the Uconnect's only media sink is picked`() { + assertEquals(5, ServiceDiscovery.pickMediaSink(ServiceDiscovery.findAudioServices(uconnectResponse()))!!.serviceId) + } + + @Test + fun `a PCM media sink wins over an earlier AAC one`() { + val sink = ServiceDiscovery.pickMediaSink(ServiceDiscovery.findAudioServices(daciaResponse()))!! + assertEquals(5, sink.serviceId) + assertEquals("AUDIO_PCM", sink.codecName) + } + + @Test + fun `with only an AAC media sink, that one is used`() { + val aacOnly = audioSink(id = 3, codec = 4, audioType = 3, config = audioConfig(48_000, 16, 2)) + assertEquals(3, ServiceDiscovery.pickMediaSink(ServiceDiscovery.findAudioServices(aacOnly))!!.serviceId) + } + + @Test + fun `no media sink means none is picked`() { + val guidanceOnly = audioSink(id = 6, codec = 1, audioType = 1, config = audioConfig(16_000, 16, 1)) + assertNull(ServiceDiscovery.pickMediaSink(ServiceDiscovery.findAudioServices(guidanceOnly))) + } }