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))) + } }