From 7e4b4d6b999ca0b70d329a9337ded585da6d8b3a Mon Sep 17 00:00:00 2001 From: Seb3thehacker Date: Tue, 22 Sep 2026 00:11:59 -0700 Subject: [PATCH] Send media audio to the car without a per-drive prompt The head unit offers a media sink, but Android answers Setup with an empty reply instead of a Config message. AudioLink now falls back to the advertised sink after 2.5s. AudioCaptureService captures by audio usage, not by app, so switching media apps costs nothing further and one grant covers a whole drive. A one-time adb command (PROJECT_MEDIA) removes the consent dialog itself; the setup screen now includes it as a copyable step, next to the existing mic-permission step. Capture counts as a screen share, so Android 15 hides notification contents while it runs. ScreenShareGuard lifts that protection for the length of the capture, with a second adb grant (WRITE_SECURE_SETTINGS), and restores it afterward, including after a crash. The phone keeps playing what it captures, so AudioCaptureService also mutes STREAM_MUSIC for the drive and restores it on every exit path. Adds a debug bench (BenchAudioSink, CarPreviewActivity --ez audio true) for testing the audio path without a car, and a unit test that rebuilds the real head unit's ServiceDiscoveryResponse from a logged session to confirm the media and guidance sinks are found correctly. Co-Authored-By: Claude Sonnet 5 --- .../seb3thehacker/gearslip/BenchAudioSink.kt | 62 +++++++++ .../gearslip/CarPreviewActivity.kt | 43 +++++++ gearslip/src/main/AndroidManifest.xml | 18 ++- .../gearslip/GearslipActivity.kt | 13 ++ .../seb3thehacker/gearslip/GearslipRunner.kt | 20 +++ .../seb3thehacker/gearslip/SessionReport.kt | 3 + .../gearslip/audio/AudioCaptureService.kt | 76 ++++++++++- .../seb3thehacker/gearslip/audio/AudioLink.kt | 121 ++++++++++++++++-- .../seb3thehacker/gearslip/audio/CarAudio.kt | 64 +++++---- .../gearslip/audio/ScreenShareGuard.kt | 64 +++++++++ .../seb3thehacker/gearslip/car/CarServices.kt | 5 +- .../seb3thehacker/gearslip/car/CarSettings.kt | 11 +- .../gearslip/car/CarSettingsScreen.kt | 7 +- .../seb3thehacker/gearslip/ui/HomeScreen.kt | 70 +++++++++- .../gearslip/ServiceDiscoveryAudioTest.kt | 94 ++++++++++++++ 15 files changed, 616 insertions(+), 55 deletions(-) create mode 100644 gearslip/src/debug/java/app/seb3thehacker/gearslip/BenchAudioSink.kt create mode 100644 gearslip/src/main/java/app/seb3thehacker/gearslip/audio/ScreenShareGuard.kt create mode 100644 gearslip/src/test/java/app/seb3thehacker/gearslip/ServiceDiscoveryAudioTest.kt diff --git a/gearslip/src/debug/java/app/seb3thehacker/gearslip/BenchAudioSink.kt b/gearslip/src/debug/java/app/seb3thehacker/gearslip/BenchAudioSink.kt new file mode 100644 index 0000000..580ab76 --- /dev/null +++ b/gearslip/src/debug/java/app/seb3thehacker/gearslip/BenchAudioSink.kt @@ -0,0 +1,62 @@ +package app.seb3thehacker.gearslip + +import app.seb3thehacker.gearslip.audio.CarAudio +import kotlin.math.abs + +/** + * Debug-only stand-in for the head unit's media audio sink. + * + * The car end of the audio path can only be exercised in a car, but everything before it - the + * setting, the consent dialog, the capture service, and whether the media app allows itself to be + * heard at all - is phone-side and can be tested at a desk. This sink takes the place of + * [AudioLink] so that path runs to completion, and reports what arrived. + * + * adb shell am start -n app.seb3thehacker.gearslip/.CarPreviewActivity \ + * --ei w 800 --ei h 480 --ez audio true --ei snaps 10 + * + * A healthy run logs a rising byte count and a peak well above zero. A byte count that rises with + * a peak of zero means the app refused to be captured. + */ +class BenchAudioSink(override val sampleRate: Int = 48_000, override val channels: Int = 2) : CarAudio.Link { + + private var chunks = 0 + private var bytes = 0L + private var peak = 0 + private var reportedAt = 0L + + override fun start() { + chunks = 0 + bytes = 0 + peak = 0 + reportedAt = System.currentTimeMillis() + GearslipLog.i("bench sink: started - pretending to be a ${sampleRate}Hz x$channels car sink") + } + + override fun stop() { + GearslipLog.i("bench sink: stopped after $chunks chunks / $bytes bytes") + } + + override fun write(pcm: ByteArray, size: Int) { + chunks++ + bytes += size + for (i in 0 until size - 1 step 2) { + val sample = (pcm[i].toInt() and 0xFF) or (pcm[i + 1].toInt() shl 8) + peak = maxOf(peak, abs(sample.toShort().toInt())) + } + val now = System.currentTimeMillis() + if (now - reportedAt < REPORT_MS) return + reportedAt = now + val seconds = bytes / (sampleRate.toDouble() * channels * 2) + GearslipLog.i( + "bench sink: %d chunks / %d bytes (%.1fs of audio), peak %.3f%s".format( + chunks, bytes, seconds, peak / 32768f, + if (peak == 0) " - SILENT, the app is not letting itself be captured" else "", + ), + ) + peak = 0 + } + + companion object { + private const val REPORT_MS = 2_000L + } +} diff --git a/gearslip/src/debug/java/app/seb3thehacker/gearslip/CarPreviewActivity.kt b/gearslip/src/debug/java/app/seb3thehacker/gearslip/CarPreviewActivity.kt index 3305e4b..f222953 100644 --- a/gearslip/src/debug/java/app/seb3thehacker/gearslip/CarPreviewActivity.kt +++ b/gearslip/src/debug/java/app/seb3thehacker/gearslip/CarPreviewActivity.kt @@ -9,6 +9,9 @@ import android.os.Handler import android.os.Looper import android.view.MotionEvent import androidx.activity.ComponentActivity +import androidx.activity.result.contract.ActivityResultContracts +import app.seb3thehacker.gearslip.audio.AudioCaptureService +import app.seb3thehacker.gearslip.audio.CarAudio import app.seb3thehacker.gearslip.car.CarEnvironment import app.seb3thehacker.gearslip.car.CarSettings import app.seb3thehacker.gearslip.car.CarUi @@ -31,6 +34,28 @@ class CarPreviewActivity : ComponentActivity() { private var reader: ImageReader? = null @Volatile private var latest: Bitmap? = null + private val audioPermission = registerForActivityResult(ActivityResultContracts.RequestPermission()) { granted -> + if (granted) askAudioCapture() else CarAudio.denied() + } + + private val audioConsent = registerForActivityResult( + androidx.activity.result.contract.ActivityResultContracts.StartActivityForResult(), + ) { result -> + val data = result.data + if (result.resultCode == RESULT_OK && data != null) { + AudioCaptureService.start(this, result.resultCode, data) + } else { + GearslipLog.w("preview: audio capture consent declined") + CarAudio.denied() + } + } + + private fun askAudioCapture() { + val manager = getSystemService(android.media.projection.MediaProjectionManager::class.java) + runCatching { audioConsent.launch(manager.createScreenCaptureIntent()) } + .onFailure { GearslipLog.e("preview: could not ask for audio capture", it); CarAudio.denied() } + } + override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) GearslipLog.init(this) @@ -58,6 +83,22 @@ class CarPreviewActivity : ComponentActivity() { val p = ScreenProjector(this).also { projector = it } p.start(imageReader.surface, w, h, dpi) { CarUi() } + // Stand in for the car's media sink, so the phone half of the audio path runs for real. + if (intent.getBooleanExtra("audio", false)) { + CarAudio.onConsentRequested = { + runOnUiThread { + if (checkSelfPermission(android.Manifest.permission.RECORD_AUDIO) == + android.content.pm.PackageManager.PERMISSION_GRANTED + ) { + askAudioCapture() + } else { + audioPermission.launch(android.Manifest.permission.RECORD_AUDIO) + } + } + } + CarAudio.attach(BenchAudioSink()) + } + GearslipLog.i("preview: displayId=${p.displayId}") if (intent.getBooleanExtra("hide", false)) p.setOverlayVisible(false) @@ -123,6 +164,8 @@ class CarPreviewActivity : ComponentActivity() { super.onDestroy() projector?.stop() reader?.close() + CarAudio.onConsentRequested = null + CarAudio.release() CarEnvironment.stop() } } diff --git a/gearslip/src/main/AndroidManifest.xml b/gearslip/src/main/AndroidManifest.xml index 6b47070..cb2ee71 100644 --- a/gearslip/src/main/AndroidManifest.xml +++ b/gearslip/src/main/AndroidManifest.xml @@ -1,5 +1,6 @@ - + @@ -23,6 +24,21 @@ + + + + + +