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 @@ + + + + + +