mirror of
https://github.com/twinksoftware/Gearslip.git
synced 2026-11-10 16:02:25 +01:00
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 <noreply@anthropic.com>
This commit is contained in:
1 parent
a8b49a3672
commit
7e4b4d6b99
15 files changed
+616
-55
No files matched your search
@@ -112,6 +112,14 @@ class GearslipActivity : ComponentActivity(), Projection {
|
||||
if (checkSelfPermission(android.Manifest.permission.READ_CALENDAR) !=
|
||||
android.content.pm.PackageManager.PERMISSION_GRANTED
|
||||
) calendarPermission.launch(android.Manifest.permission.READ_CALENDAR)
|
||||
// Playback capture needs the microphone even though it never records one. Ask for it here,
|
||||
// at the kerb, rather than the first time a song plays: a permission dialog that appears
|
||||
// mid-drive is one the driver has to read and answer at the wheel, and missing it is
|
||||
// indistinguishable from the car link being broken.
|
||||
if (CarSettings.pipeAudio.value &&
|
||||
checkSelfPermission(android.Manifest.permission.RECORD_AUDIO) !=
|
||||
android.content.pm.PackageManager.PERMISSION_GRANTED
|
||||
) audioPermission.launch(android.Manifest.permission.RECORD_AUDIO)
|
||||
enableEdgeToEdge()
|
||||
window.addFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON)
|
||||
|
||||
@@ -119,6 +127,11 @@ class GearslipActivity : ComponentActivity(), Projection {
|
||||
GearslipLog.installCrashHandler()
|
||||
GearslipLog.i("Gearslip ready")
|
||||
CarSettings.init(this)
|
||||
// A capture that died without tidying up would leave notifications exposed to every
|
||||
// later screen share. Nothing is capturing if the service is not running, so undo it.
|
||||
if (!app.seb3thehacker.gearslip.audio.AudioCaptureService.running) {
|
||||
app.seb3thehacker.gearslip.audio.ScreenShareGuard.restore(this)
|
||||
}
|
||||
Prefetch.warm(this)
|
||||
CarEnvironment.setPhoneTheme(resources.configuration)
|
||||
|
||||
|
||||
@@ -51,6 +51,7 @@ class GearslipRunner(
|
||||
@Volatile private var dropped = 0
|
||||
@Volatile private var inputChannelId: Int = -1
|
||||
@Volatile private var audioLink: AudioLink? = null
|
||||
private var audioMessagesSeen = 0
|
||||
private var touchWidth = 0
|
||||
private var touchHeight = 0
|
||||
private var touchesSeen = 0
|
||||
@@ -142,6 +143,16 @@ class GearslipRunner(
|
||||
} else if (frame.channel == inputChannelId) {
|
||||
onInputMessage(messageId, body)
|
||||
} else if (frame.channel == audioLink?.channelId) {
|
||||
// The audio channel is the one still being reverse-engineered, and its first few
|
||||
// replies are what tell us why. Raw bytes for those, then quiet.
|
||||
if (audioMessagesSeen < AUDIO_TRACE_MESSAGES) {
|
||||
audioMessagesSeen++
|
||||
log.i(
|
||||
"audio frame #$audioMessagesSeen: channel=${frame.channel} " +
|
||||
"encrypted=${frame.encrypted} raw=${frame.payload.size}B decrypted=${message.size}B",
|
||||
)
|
||||
log.hex(" audio raw", message, limit = 64)
|
||||
}
|
||||
audioLink?.onMessage(messageId, body)
|
||||
} else {
|
||||
log.i("ignoring message id $messageId on channel ${frame.channel}")
|
||||
@@ -360,8 +371,16 @@ class GearslipRunner(
|
||||
}
|
||||
val media = sinks.firstOrNull { it.streamType == STREAM_MEDIA } ?: 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
|
||||
}
|
||||
val first = media.configs.firstOrNull()
|
||||
SessionReport.audio(
|
||||
"MEDIA sink on channel ${media.serviceId}, ${media.codecName}, " +
|
||||
"${first?.sampleRate ?: 48_000}Hz/${first?.bits ?: 16}bit/x${first?.channels ?: 2}",
|
||||
)
|
||||
audioLink = AudioLink(
|
||||
media,
|
||||
sendOnChannel = { id, body -> send(id, body, encrypted = true, channel = media.serviceId) },
|
||||
@@ -799,6 +818,7 @@ class GearslipRunner(
|
||||
const val ACTION_DOWN = 0
|
||||
const val ACTION_UP = 1
|
||||
const val ACK_STALL_MS = 2_000L
|
||||
const val AUDIO_TRACE_MESSAGES = 6
|
||||
const val PERIODIC_KEYFRAME_MS = 5_000L
|
||||
}
|
||||
}
|
||||
@@ -42,6 +42,7 @@ object SessionReport {
|
||||
@Volatile private var headUnit = ""
|
||||
@Volatile private var profile = ""
|
||||
@Volatile private var video = ""
|
||||
@Volatile private var audio = ""
|
||||
@Volatile private var lastStream = ""
|
||||
@Volatile private var lastState = ""
|
||||
private var printed = false
|
||||
@@ -68,6 +69,7 @@ object SessionReport {
|
||||
fun headUnit(info: String) { headUnit = info }
|
||||
fun profile(name: String?) { profile = name ?: "none matched" }
|
||||
fun video(summary: String) { video = summary }
|
||||
fun audio(summary: String) { audio = summary }
|
||||
fun stream(summary: String) { lastStream = summary }
|
||||
|
||||
/** Records the first failure only: later ones are usually fallout from it. */
|
||||
@@ -107,6 +109,7 @@ object SessionReport {
|
||||
append(row("auth status", authStatus?.let { "$it (${authName(it)})" } ?: ""))
|
||||
append("\n")
|
||||
append(row("video", video))
|
||||
append(row("audio", audio))
|
||||
append(row("last stream stat", lastStream))
|
||||
append("=".repeat(60))
|
||||
}
|
||||
|
||||
@@ -24,16 +24,51 @@ import kotlin.math.abs
|
||||
* Playback capture is the one public route to another app's sound, and it hangs off a
|
||||
* MediaProjection - so, as with [app.seb3thehacker.gearslip.mirror.MirrorService], the consent
|
||||
* result is carried into a foreground service of type mediaProjection before it can be redeemed.
|
||||
* Capture is scoped to the media app's uid: notification pings and other apps stay on the phone.
|
||||
* Capture is scoped to media playback: notification pings, calls and alarms stay on the phone.
|
||||
* The grant lasts as long as this service does, which is as long as the car is connected, so the
|
||||
* driver answers the consent dialog once per drive rather than once per app.
|
||||
*/
|
||||
class AudioCaptureService : Service() {
|
||||
|
||||
@Volatile private var reading = false
|
||||
private var projection: MediaProjection? = null
|
||||
private var thread: Thread? = null
|
||||
private var mutedPhone = false
|
||||
|
||||
/**
|
||||
* Silence the phone's own speaker while the car is being fed.
|
||||
*
|
||||
* Playback capture copies the stream instead of moving it, so without this the music plays in
|
||||
* two places at once. Muting the stream does not affect what is captured - the tap sits before
|
||||
* the volume the speaker gets - so the car still receives full-level audio.
|
||||
*/
|
||||
private fun mutePhone() {
|
||||
if (mutedPhone) return
|
||||
runCatching {
|
||||
getSystemService(android.media.AudioManager::class.java)
|
||||
.adjustStreamVolume(android.media.AudioManager.STREAM_MUSIC, android.media.AudioManager.ADJUST_MUTE, 0)
|
||||
mutedPhone = true
|
||||
GearslipLog.i("audio: muted the phone's speaker for the drive")
|
||||
}.onFailure { GearslipLog.w("audio: could not mute the phone: ${it.message}") }
|
||||
}
|
||||
|
||||
private fun unmutePhone() {
|
||||
if (!mutedPhone) return
|
||||
mutedPhone = false
|
||||
runCatching {
|
||||
getSystemService(android.media.AudioManager::class.java)
|
||||
.adjustStreamVolume(android.media.AudioManager.STREAM_MUSIC, android.media.AudioManager.ADJUST_UNMUTE, 0)
|
||||
GearslipLog.i("audio: gave the phone's speaker back")
|
||||
}.onFailure { GearslipLog.w("audio: could not unmute the phone: ${it.message}") }
|
||||
}
|
||||
|
||||
override fun onBind(intent: Intent?): IBinder? = null
|
||||
|
||||
override fun onCreate() {
|
||||
super.onCreate()
|
||||
running = true
|
||||
}
|
||||
|
||||
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
|
||||
if (intent?.action == ACTION_STOP) {
|
||||
stopSelf()
|
||||
@@ -49,6 +84,7 @@ class AudioCaptureService : Service() {
|
||||
}
|
||||
|
||||
startForeground(NOTIFICATION_ID, notification(), ServiceInfo.FOREGROUND_SERVICE_TYPE_MEDIA_PROJECTION)
|
||||
ScreenShareGuard.lift(this)
|
||||
projection = try {
|
||||
getSystemService(MediaProjectionManager::class.java).getMediaProjection(resultCode, data)
|
||||
} catch (t: Throwable) {
|
||||
@@ -74,13 +110,14 @@ class AudioCaptureService : Service() {
|
||||
private fun beginCapture(token: MediaProjection) {
|
||||
val rate = CarAudio.sampleRate
|
||||
val channels = CarAudio.channels
|
||||
val uid = CarAudio.uid
|
||||
val record = try {
|
||||
// Matched by usage, not by app. A uid filter would have to be rebuilt every time the
|
||||
// driver changed media app, and rebuilding means a fresh consent dialog - so the
|
||||
// filter is what the sound is for, and any app playing media reaches the car.
|
||||
val config = AudioPlaybackCaptureConfiguration.Builder(token)
|
||||
.addMatchingUsage(AudioAttributes.USAGE_MEDIA)
|
||||
.addMatchingUsage(AudioAttributes.USAGE_GAME)
|
||||
.addMatchingUsage(AudioAttributes.USAGE_UNKNOWN)
|
||||
.apply { if (uid >= 0) addMatchingUid(uid) }
|
||||
.build()
|
||||
val mask = if (channels == 1) AudioFormat.CHANNEL_IN_MONO else AudioFormat.CHANNEL_IN_STEREO
|
||||
val format = AudioFormat.Builder()
|
||||
@@ -106,17 +143,25 @@ class AudioCaptureService : Service() {
|
||||
val buffer = ByteArray(CHUNK_BYTES)
|
||||
var peak = 0
|
||||
var heardAt = System.currentTimeMillis()
|
||||
val startedAt = heardAt
|
||||
var everHeard = false
|
||||
var warnedSilent = false
|
||||
try {
|
||||
record.startRecording()
|
||||
GearslipLog.i("audio: capturing uid=$uid at ${rate}Hz x$channels")
|
||||
GearslipLog.i("audio: capturing media playback at ${rate}Hz x$channels")
|
||||
mutePhone()
|
||||
CarAudio.captureStarted()
|
||||
while (reading) {
|
||||
val n = record.read(buffer, 0, buffer.size)
|
||||
if (n <= 0) break
|
||||
if (n <= 0) {
|
||||
GearslipLog.e("audio: AudioRecord.read returned $n - capture has stopped")
|
||||
break
|
||||
}
|
||||
for (i in 0 until n - 1 step 2) {
|
||||
val sample = (buffer[i].toInt() and 0xFF) or (buffer[i + 1].toInt() shl 8)
|
||||
peak = maxOf(peak, abs(sample.toShort().toInt()))
|
||||
}
|
||||
if (peak > 0) everHeard = true
|
||||
val now = System.currentTimeMillis()
|
||||
val level = peak / 32768f
|
||||
if (now - heardAt >= LEVEL_LOG_MS) {
|
||||
@@ -124,6 +169,16 @@ class AudioCaptureService : Service() {
|
||||
heardAt = now
|
||||
peak = 0
|
||||
}
|
||||
// Capture never fails for an app that opts out; it just hands back digital
|
||||
// silence. Say so, because the symptom is identical to a broken car link.
|
||||
if (!everHeard && !warnedSilent && now - startedAt >= SILENCE_MS) {
|
||||
warnedSilent = true
|
||||
GearslipLog.w(
|
||||
"audio: ${SILENCE_MS / 1000}s of capture and every sample is zero. " +
|
||||
"Either nothing is playing, or the app does not allow its audio " +
|
||||
"to be captured.",
|
||||
)
|
||||
}
|
||||
CarAudio.deliver(buffer, n, level)
|
||||
}
|
||||
} catch (t: Throwable) {
|
||||
@@ -131,6 +186,7 @@ class AudioCaptureService : Service() {
|
||||
} finally {
|
||||
runCatching { record.stop() }
|
||||
record.release()
|
||||
unmutePhone()
|
||||
CarAudio.captureStopped()
|
||||
}
|
||||
}, "gearslip-audio").apply { isDaemon = true; start() }
|
||||
@@ -140,8 +196,13 @@ class AudioCaptureService : Service() {
|
||||
super.onDestroy()
|
||||
reading = false
|
||||
runCatching { thread?.join(500) }
|
||||
// Belt and braces: a phone left muted after the drive is a bug the driver blames on their
|
||||
// phone, not on Gearslip, so unmute on every way out of here.
|
||||
unmutePhone()
|
||||
runCatching { projection?.stop() }
|
||||
projection = null
|
||||
ScreenShareGuard.restore(this)
|
||||
running = false
|
||||
}
|
||||
|
||||
private fun notification(): Notification {
|
||||
@@ -173,9 +234,14 @@ class AudioCaptureService : Service() {
|
||||
/** About 21 ms of 48 kHz stereo: small enough for lip-sync, big enough not to flood the link. */
|
||||
private const val CHUNK_BYTES = 4096
|
||||
private const val LEVEL_LOG_MS = 3_000L
|
||||
private const val SILENCE_MS = 5_000L
|
||||
|
||||
private var appContext: Context? = null
|
||||
|
||||
/** True between onCreate and onDestroy; lets the activity tell a live capture from a crashed one. */
|
||||
@Volatile var running = false
|
||||
private set
|
||||
|
||||
fun start(context: Context, resultCode: Int, data: Intent) {
|
||||
appContext = context.applicationContext
|
||||
context.startForegroundService(
|
||||
|
||||
@@ -27,10 +27,16 @@ class AudioLink(
|
||||
private var configIndex = 0
|
||||
@Volatile private var ready = false
|
||||
@Volatile private var started = false
|
||||
@Volatile private var focusGranted = false
|
||||
@Volatile private var focusRequested = false
|
||||
@Volatile private var inFlight = 0
|
||||
@Volatile private var maxUnacked = 8
|
||||
// The Uconnect asks for 4 on its video channel; assume the same until its Config says otherwise.
|
||||
@Volatile private var maxUnacked = 4
|
||||
@Volatile private var lastAckAt = 0L
|
||||
private var chunks = 0
|
||||
private var bytes = 0L
|
||||
private var dropped = 0
|
||||
@Volatile private var configTimer: Thread? = null
|
||||
|
||||
private val config get() = service.configs.getOrNull(configIndex) ?: service.configs.firstOrNull()
|
||||
override val sampleRate get() = config?.sampleRate ?: 48_000
|
||||
@@ -39,12 +45,47 @@ class AudioLink(
|
||||
val channelId get() = service.serviceId
|
||||
|
||||
fun opened(status: Int) {
|
||||
GearslipLog.i("audio: <- ChannelOpenResponse (media audio): status=$status")
|
||||
if (status != 0) {
|
||||
GearslipLog.w("audio: the head unit refused the media audio channel (status $status)")
|
||||
return
|
||||
}
|
||||
sendOnChannel(MSG_SETUP, Protobuf.varintField(1, service.codecType.toLong()))
|
||||
GearslipLog.i("audio: -> Setup(codec=${service.codecName})")
|
||||
// AVChannelSetupRequest { required uint32 config_index = 1 }. This field is an index into
|
||||
// the sink's own audio_configs, not a codec id - sending the codec type asks for a config
|
||||
// the sink does not have, and it answers with a status that never reaches READY.
|
||||
sendOnChannel(MSG_SETUP, Protobuf.varintField(1, 0L))
|
||||
GearslipLog.i("audio: -> Setup(config_index=0) on channel ${service.serviceId}, codec ${service.codecName}")
|
||||
armConfigFallback()
|
||||
}
|
||||
|
||||
/**
|
||||
* The 2018 Uconnect answers our Setup with a two-byte message we cannot identify (id 255, no
|
||||
* body) rather than the AVChannelSetupResponse the video channel gets, so the sink never
|
||||
* reaches READY and no audio is ever sent.
|
||||
*
|
||||
* Service discovery already told us everything that message would have: the sink is PCM at
|
||||
* 48000Hz stereo and it has exactly one configuration. So rather than wait forever for a
|
||||
* message that is not coming, assume the advertised configuration and carry on. If that guess
|
||||
* is wrong the head unit simply will not ack, which the write path already reports.
|
||||
*/
|
||||
private fun armConfigFallback() {
|
||||
val timer = Thread({
|
||||
try {
|
||||
Thread.sleep(CONFIG_WAIT_MS)
|
||||
} catch (e: InterruptedException) {
|
||||
return@Thread
|
||||
}
|
||||
if (ready) return@Thread
|
||||
GearslipLog.w(
|
||||
"audio: no usable Config after ${CONFIG_WAIT_MS}ms - falling back to the sink the " +
|
||||
"head unit advertised (${sampleRate}Hz x$channels, config_index=0, max_unacked=$maxUnacked)",
|
||||
)
|
||||
configIndex = 0
|
||||
ready = true
|
||||
CarAudio.attach(this)
|
||||
}, "gearslip-audio-config").apply { isDaemon = true }
|
||||
configTimer = timer
|
||||
timer.start()
|
||||
}
|
||||
|
||||
fun onMessage(messageId: Int, body: ByteArray) {
|
||||
@@ -60,9 +101,15 @@ class AudioLink(
|
||||
if (window != null) maxUnacked = maxOf(1, window)
|
||||
configIndex = indices.firstOrNull() ?: 0
|
||||
if (status == STATUS_READY) {
|
||||
configTimer?.interrupt()
|
||||
ready = true
|
||||
GearslipLog.i("audio: ready - ${sampleRate}Hz x$channels ${config?.bits}bit")
|
||||
GearslipLog.i("audio: sink ready - ${sampleRate}Hz x$channels ${config?.bits}bit")
|
||||
CarAudio.attach(this)
|
||||
} else {
|
||||
GearslipLog.w(
|
||||
"audio: the sink is not ready (status=$status, 1=WAIT 2=READY) - " +
|
||||
"nothing will be sent until it is",
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -72,17 +119,40 @@ class AudioLink(
|
||||
lastAckAt = System.currentTimeMillis()
|
||||
}
|
||||
|
||||
else -> GearslipLog.i("audio: <- unhandled message id=$messageId (${body.size} bytes)")
|
||||
// Whatever the head unit answers Setup with, we need its bytes to identify it.
|
||||
else -> {
|
||||
GearslipLog.i("audio: <- unhandled message id=$messageId (${body.size} bytes)")
|
||||
GearslipLog.hex(" audio msg", body, limit = 64)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The head unit's answer to our AudioFocusRequest. Until it grants focus the sink is wired to
|
||||
* whatever else the car is playing - the radio, usually - and anything we send is discarded,
|
||||
* so [start] waits here rather than streaming into nothing.
|
||||
*/
|
||||
fun onFocus(state: Int) {
|
||||
GearslipLog.i("audio: <- AudioFocus state=$state (1=GAIN, 2=GAIN_TRANSIENT, 3=LOSS, 4=LOSS_TRANSIENT)")
|
||||
val name = FOCUS_STATES[state] ?: "unknown($state)"
|
||||
GearslipLog.i("audio: <- AudioFocusResponse state=$name")
|
||||
focusGranted = state == FOCUS_STATE_GAIN || state == FOCUS_STATE_GAIN_TRANSIENT
|
||||
if (focusGranted) startStream() else stop()
|
||||
}
|
||||
|
||||
override fun start() {
|
||||
if (!ready || started) return
|
||||
sendControl(MSG_FOCUS_REQUEST, Protobuf.varintField(1, FOCUS_GAIN.toLong()))
|
||||
if (!focusRequested) {
|
||||
focusRequested = true
|
||||
sendControl(MSG_FOCUS_REQUEST, Protobuf.varintField(1, FOCUS_GAIN.toLong()))
|
||||
GearslipLog.i("audio: -> AudioFocusRequest(GAIN); waiting for the head unit to hand the sink over")
|
||||
}
|
||||
// Some head units grant the sink silently and never answer. Do not let that be the end of
|
||||
// it: start anyway, and let the acks say whether anything is being taken.
|
||||
startStream()
|
||||
}
|
||||
|
||||
private fun startStream() {
|
||||
if (!ready || started) return
|
||||
sendOnChannel(
|
||||
MSG_START,
|
||||
Protobuf.varintField(1, SESSION_ID.toLong()) + Protobuf.varintField(2, configIndex.toLong()),
|
||||
@@ -90,7 +160,7 @@ class AudioLink(
|
||||
inFlight = 0
|
||||
lastAckAt = System.currentTimeMillis()
|
||||
started = true
|
||||
GearslipLog.i("audio: -> AudioFocusRequest(GAIN), Start(config=$configIndex)")
|
||||
GearslipLog.i("audio: -> Start(session=$SESSION_ID, config=$configIndex) - streaming ${sampleRate}Hz x$channels")
|
||||
}
|
||||
|
||||
override fun stop() {
|
||||
@@ -98,17 +168,26 @@ class AudioLink(
|
||||
started = false
|
||||
runCatching {
|
||||
sendOnChannel(MSG_STOP, Protobuf.varintField(1, SESSION_ID.toLong()))
|
||||
sendControl(MSG_FOCUS_REQUEST, Protobuf.varintField(1, FOCUS_RELEASE.toLong()))
|
||||
if (focusRequested) sendControl(MSG_FOCUS_REQUEST, Protobuf.varintField(1, FOCUS_RELEASE.toLong()))
|
||||
}
|
||||
GearslipLog.i("audio: -> Stop, AudioFocusRequest(RELEASE) after $chunks chunks")
|
||||
focusRequested = false
|
||||
focusGranted = false
|
||||
GearslipLog.i("audio: -> Stop, AudioFocusRequest(RELEASE) after $chunks chunks / $bytes bytes")
|
||||
chunks = 0
|
||||
bytes = 0
|
||||
}
|
||||
|
||||
override fun write(pcm: ByteArray, size: Int) {
|
||||
if (!started) return
|
||||
if (inFlight >= maxUnacked) {
|
||||
// A lost ack must not silence the stream for good; after a stall assume the window cleared.
|
||||
if (System.currentTimeMillis() - lastAckAt > STALL_MS) inFlight = 0 else return
|
||||
if (System.currentTimeMillis() - lastAckAt > STALL_MS) {
|
||||
GearslipLog.w("audio: no ack for ${STALL_MS}ms with $inFlight in flight - reopening the window")
|
||||
inFlight = 0
|
||||
} else {
|
||||
dropped++
|
||||
return
|
||||
}
|
||||
}
|
||||
val timestamp = ByteArray(8)
|
||||
val micros = System.nanoTime() / 1_000
|
||||
@@ -116,14 +195,26 @@ class AudioLink(
|
||||
try {
|
||||
sendOnChannel(MSG_DATA, timestamp + pcm.copyOf(size))
|
||||
} catch (e: java.io.IOException) {
|
||||
GearslipLog.e("audio: the link died mid-stream", e)
|
||||
started = false
|
||||
return
|
||||
}
|
||||
inFlight++
|
||||
chunks++
|
||||
bytes += size
|
||||
// The first chunk proves the whole path; after that, an occasional line is enough to show
|
||||
// the car is still taking audio and to catch a window that has quietly closed.
|
||||
if (chunks == 1 || chunks % CHUNK_LOG_EVERY == 0) {
|
||||
GearslipLog.i(
|
||||
"audio: -> $chunks chunks / $bytes bytes, inFlight=$inFlight/$maxUnacked, " +
|
||||
"dropped=$dropped, focus=${if (focusGranted) "granted" else "not granted"}",
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
fun close() {
|
||||
configTimer?.interrupt()
|
||||
configTimer = null
|
||||
stop()
|
||||
ready = false
|
||||
CarAudio.detach(this)
|
||||
@@ -142,7 +233,15 @@ class AudioLink(
|
||||
private const val STATUS_READY = 2
|
||||
private const val FOCUS_GAIN = 1
|
||||
private const val FOCUS_RELEASE = 4
|
||||
private const val FOCUS_STATE_GAIN = 1
|
||||
private const val FOCUS_STATE_GAIN_TRANSIENT = 2
|
||||
private const val SESSION_ID = 2
|
||||
private const val STALL_MS = 2_000L
|
||||
private const val CHUNK_LOG_EVERY = 200 // about every four seconds
|
||||
private const val CONFIG_WAIT_MS = 2_500L
|
||||
|
||||
private val FOCUS_STATES = mapOf(
|
||||
1 to "GAIN", 2 to "GAIN_TRANSIENT", 3 to "LOSS", 4 to "LOSS_TRANSIENT",
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -6,12 +6,15 @@ import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
|
||||
/**
|
||||
* Joins the two ends of the audio path: [AudioCaptureService] on the phone, which hears what a
|
||||
* media app plays, and the car's audio channel, which is where it has to end up.
|
||||
* Joins the two ends of the audio path: [AudioCaptureService] on the phone, which hears what the
|
||||
* phone is playing, and the car's audio channel, which is where it has to end up.
|
||||
*
|
||||
* They come and go independently. The phone side needs on-device consent and can be running on
|
||||
* the bench with no car attached; the car side exists only while a head unit is connected and
|
||||
* offers a media audio sink. Whichever pair is present, samples flow.
|
||||
* Capture belongs to the car session, not to any one app. Android grants it through a consent
|
||||
* dialog that cannot be answered in advance or remembered, so Gearslip asks once, when a head unit
|
||||
* first offers a sink to play through, and then holds that grant for as long as the car is
|
||||
* connected. Switching between media apps, or starting playback from the phone, costs nothing
|
||||
* further: the capture is by audio usage rather than by app, so whatever the phone plays as media
|
||||
* goes to the car.
|
||||
*/
|
||||
object CarAudio {
|
||||
|
||||
@@ -36,9 +39,8 @@ object CarAudio {
|
||||
/** Set by the activity: shows the permission and consent dialogs, which only it can. */
|
||||
var onConsentRequested: (() -> Unit)? = null
|
||||
|
||||
/** The uid being captured (or waited on). */
|
||||
@Volatile var uid: Int = -1
|
||||
private set
|
||||
/** Whether the driver has asked for the car to be fed at all; see CarSettings.pipeAudio. */
|
||||
@Volatile private var wanted = false
|
||||
|
||||
@Volatile private var link: Link? = null
|
||||
|
||||
@@ -48,30 +50,48 @@ object CarAudio {
|
||||
|
||||
fun attach(link: Link) {
|
||||
this.link = link
|
||||
if (_capture.value == Capture.ON) link.start()
|
||||
GearslipLog.i("audio: the car offered a sink at ${link.sampleRate}Hz x${link.channels}")
|
||||
when {
|
||||
_capture.value == Capture.ON -> link.start()
|
||||
wanted -> ask()
|
||||
}
|
||||
}
|
||||
|
||||
fun detach(link: Link) {
|
||||
if (this.link === link) this.link = null
|
||||
}
|
||||
|
||||
/** A media app was opened: start hearing it. */
|
||||
fun request(uid: Int) {
|
||||
if (this.uid == uid && _capture.value != Capture.OFF && _capture.value != Capture.DENIED) return
|
||||
if (this.uid != uid) AudioCaptureService.stop()
|
||||
this.uid = uid
|
||||
_capture.value = Capture.ASKING
|
||||
val ask = onConsentRequested
|
||||
if (ask == null) {
|
||||
GearslipLog.w("audio: nothing on the phone can ask for capture consent")
|
||||
_capture.value = Capture.DENIED
|
||||
} else {
|
||||
ask()
|
||||
/**
|
||||
* The car wants sound. Safe to call as often as the session likes: the consent dialog appears
|
||||
* at most once, and only once there is a sink to play through - asking on the bench, where the
|
||||
* sound has nowhere to go, would put a system dialog in front of the driver for nothing.
|
||||
*/
|
||||
fun request() {
|
||||
wanted = true
|
||||
when (_capture.value) {
|
||||
Capture.ON, Capture.ASKING, Capture.DENIED -> return
|
||||
Capture.OFF -> if (link == null) {
|
||||
GearslipLog.i("audio: waiting for the car to offer an audio sink")
|
||||
} else {
|
||||
ask()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun ask() {
|
||||
_capture.value = Capture.ASKING
|
||||
val request = onConsentRequested
|
||||
if (request == null) {
|
||||
GearslipLog.w("audio: nothing on the phone can ask for capture consent")
|
||||
_capture.value = Capture.DENIED
|
||||
} else {
|
||||
request()
|
||||
}
|
||||
}
|
||||
|
||||
/** The car went away. Everything is torn down; the next connection asks again. */
|
||||
fun release() {
|
||||
uid = -1
|
||||
wanted = false
|
||||
AudioCaptureService.stop()
|
||||
_capture.value = Capture.OFF
|
||||
}
|
||||
|
||||
@@ -0,0 +1,64 @@
|
||||
package app.seb3thehacker.gearslip.audio
|
||||
|
||||
import android.Manifest
|
||||
import android.content.Context
|
||||
import android.content.pm.PackageManager
|
||||
import android.provider.Settings
|
||||
import app.seb3thehacker.gearslip.GearslipLog
|
||||
|
||||
/**
|
||||
* Keeps the phone's notifications readable while audio is being sent to the car.
|
||||
*
|
||||
* Playback capture hangs off a MediaProjection, and since Android 15 any full-screen projection
|
||||
* makes SystemUI hide notification contents, on the assumption that someone is watching the
|
||||
* screen. Nobody is: the audio projection never makes a display. The one exemption an ordinary app
|
||||
* can reach is the developer setting that switches the protection off, so Gearslip turns it off for
|
||||
* the length of the capture and back on afterwards.
|
||||
*
|
||||
* Writing it needs WRITE_SECURE_SETTINGS, granted once over adb. Without the grant this does
|
||||
* nothing and notifications stay hidden while music plays, which is the platform's default.
|
||||
*
|
||||
* Phone mirroring deliberately does not use this: there the screen really is being shown.
|
||||
*/
|
||||
internal object ScreenShareGuard {
|
||||
|
||||
/** Settings.Global.DISABLE_SCREEN_SHARE_PROTECTIONS_FOR_APPS_AND_NOTIFICATIONS, hidden API. */
|
||||
private const val SETTING = "disable_screen_share_protections_for_apps_and_notifications"
|
||||
private const val PREFS = "gearslip_screen_share_guard"
|
||||
|
||||
/** Set while Gearslip has the protection switched off, so a crash can be undone at next launch. */
|
||||
private const val KEY_LIFTED = "lifted"
|
||||
|
||||
fun canLift(context: Context): Boolean =
|
||||
context.checkSelfPermission(Manifest.permission.WRITE_SECURE_SETTINGS) ==
|
||||
PackageManager.PERMISSION_GRANTED
|
||||
|
||||
/** Call before the projection starts: the system decides what to hide at that moment. */
|
||||
fun lift(context: Context) {
|
||||
if (!canLift(context)) {
|
||||
GearslipLog.i("audio: notifications will be hidden while capturing (no WRITE_SECURE_SETTINGS)")
|
||||
return
|
||||
}
|
||||
val resolver = context.contentResolver
|
||||
// Already off, whether by the driver in Developer options or by us: leave it as found.
|
||||
if (Settings.Global.getInt(resolver, SETTING, 0) != 0) return
|
||||
runCatching {
|
||||
prefs(context).edit().putBoolean(KEY_LIFTED, true).commit()
|
||||
Settings.Global.putInt(resolver, SETTING, 1)
|
||||
GearslipLog.i("audio: screen share protection off for the drive, notifications stay visible")
|
||||
}.onFailure { GearslipLog.w("audio: could not lift screen share protection: ${it.message}") }
|
||||
}
|
||||
|
||||
/** Put the protection back, if Gearslip is the one that took it away. */
|
||||
fun restore(context: Context) {
|
||||
if (!prefs(context).getBoolean(KEY_LIFTED, false)) return
|
||||
runCatching {
|
||||
Settings.Global.putInt(context.contentResolver, SETTING, 0)
|
||||
prefs(context).edit().remove(KEY_LIFTED).commit()
|
||||
GearslipLog.i("audio: screen share protection back on")
|
||||
}.onFailure { GearslipLog.w("audio: could not restore screen share protection: ${it.message}") }
|
||||
}
|
||||
|
||||
private fun prefs(context: Context) =
|
||||
context.applicationContext.getSharedPreferences(PREFS, Context.MODE_PRIVATE)
|
||||
}
|
||||
@@ -62,11 +62,12 @@ object CarServices {
|
||||
val live = phase == CarMedia.Phase.READY || phase == CarMedia.Phase.CONNECTING
|
||||
if (_mediaApp.value?.component == app.component && live) return
|
||||
|
||||
CarAudio.release()
|
||||
_mediaApp.value = app
|
||||
autoplayPending = autoplay
|
||||
media.connect(app)
|
||||
if (CarSettings.pipeAudio.value) CarAudio.request(app.uid)
|
||||
// Not torn down and rebuilt per app: the capture covers whatever the phone plays, so
|
||||
// switching media apps never costs the driver another consent dialog.
|
||||
if (CarSettings.pipeAudio.value) CarAudio.request()
|
||||
}
|
||||
|
||||
/** Called once the media app is connected: starts playback if that was asked for. */
|
||||
|
||||
@@ -33,7 +33,7 @@ object CarSettings {
|
||||
private val scaleFlow = MutableStateFlow(1f)
|
||||
private val openFlow = MutableStateFlow(HOME)
|
||||
private val nightFlow = MutableStateFlow(NightMode.AUTO)
|
||||
private val pipeAudioFlow = MutableStateFlow(false)
|
||||
private val pipeAudioFlow = MutableStateFlow(true)
|
||||
private val lastNavFlow = MutableStateFlow<String?>(null)
|
||||
private val lastMediaFlow = MutableStateFlow<String?>(null)
|
||||
private val autoplayFlow = MutableStateFlow(false)
|
||||
@@ -52,9 +52,10 @@ object CarSettings {
|
||||
val nightMode: StateFlow<NightMode> = nightFlow
|
||||
|
||||
/**
|
||||
* Whether a media app's sound is captured and sent over the car link. Off by default: the
|
||||
* platform only allows that behind a screen-capture consent dialog, and without it the app
|
||||
* simply plays through the phone's normal output (e.g. Bluetooth to the car).
|
||||
* Whether a media app's sound is captured and sent over the car link. On by default, since
|
||||
* sound in the car is the point of the exercise; the platform allows the capture only behind a
|
||||
* consent dialog, which [app.seb3thehacker.gearslip.audio.CarAudio] raises once the car has
|
||||
* somewhere to play it. Turned off, the app plays through the phone's normal output instead.
|
||||
*/
|
||||
val pipeAudio: StateFlow<Boolean> = pipeAudioFlow
|
||||
|
||||
@@ -68,7 +69,7 @@ object CarSettings {
|
||||
AppTheme.valueOf(AppSettings.getString(app, KEY_THEME, AppTheme.PHONE.name))
|
||||
}.getOrDefault(AppTheme.PHONE)
|
||||
autoplayFlow.value = AppSettings.getString(app, KEY_AUTOPLAY, "false") == "true"
|
||||
pipeAudioFlow.value = AppSettings.getString(app, KEY_PIPE_AUDIO, "false") == "true"
|
||||
pipeAudioFlow.value = AppSettings.getString(app, KEY_PIPE_AUDIO, "true") == "true"
|
||||
nightFlow.value = runCatching {
|
||||
NightMode.valueOf(AppSettings.getString(app, KEY_NIGHT, NightMode.AUTO.name))
|
||||
}.getOrDefault(NightMode.AUTO)
|
||||
|
||||
@@ -98,9 +98,10 @@ fun CarSettingsScreen() {
|
||||
pipeAudio,
|
||||
) { CarSettings.setPipeAudio(it) }
|
||||
Text(
|
||||
"Phone output needs no permission: the app plays as it normally would, e.g. over " +
|
||||
"Bluetooth to the car. Through Gearslip sends the sound over the car link, and " +
|
||||
"Android asks for screen-capture consent each time.",
|
||||
"Through Gearslip sends the sound over the USB link. Android asks for consent the " +
|
||||
"first time a media app plays in the car, and some apps refuse to be captured at " +
|
||||
"all. Phone output needs no permission: the app plays as it normally would, for " +
|
||||
"example over Bluetooth to the car.",
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
|
||||
@@ -2,7 +2,6 @@ package app.seb3thehacker.gearslip.ui
|
||||
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.layout.widthIn
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
@@ -31,7 +30,11 @@ import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import android.widget.Toast
|
||||
import androidx.compose.ui.platform.LocalClipboardManager
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.text.AnnotatedString
|
||||
import androidx.compose.ui.text.font.FontFamily
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
@@ -178,8 +181,13 @@ private fun StatusCard(status: SessionStatus.Snapshot) {
|
||||
private fun Context.appVersionName(): String =
|
||||
runCatching { packageManager.getPackageInfo(packageName, 0).versionName }.getOrNull() ?: "?"
|
||||
|
||||
/** One step of the setup list: a sentence that says what to do, and optionally how. */
|
||||
private class Step(val action: String, val detail: String? = null)
|
||||
/**
|
||||
* One step of the setup list: a sentence that says what to do, and optionally how.
|
||||
*
|
||||
* [command] is for the one step that cannot be done on the phone. It is shown as something to
|
||||
* copy rather than to read, because a mistyped package name fails silently.
|
||||
*/
|
||||
private class Step(val action: String, val detail: String? = null, val command: String? = null)
|
||||
|
||||
private val SETUP_STEPS = listOf(
|
||||
Step("Use a car from 2020 or earlier.", "Newer head units reject the certificate."),
|
||||
@@ -189,20 +197,69 @@ private val SETUP_STEPS = listOf(
|
||||
),
|
||||
Step("Disable or uninstall the Android Auto app."),
|
||||
Step("Load the certificate.", "Open Settings and choose Import certificate."),
|
||||
Step(
|
||||
"Allow the microphone, so music can reach the car.",
|
||||
"Android asks for the microphone before it will let any app pass music along. " +
|
||||
"Gearslip never listens to you: it copies what a music app is playing, and nothing else.",
|
||||
),
|
||||
Step(
|
||||
"Optional: stop Android asking about audio every drive.",
|
||||
"Connect the phone to a computer with USB debugging turned on, and run this command. " +
|
||||
"It tells Android to trust Gearslip with audio from now on. Skip it and everything " +
|
||||
"still works, but you have to tap Start once each time you set off.",
|
||||
command = "adb shell appops set app.seb3thehacker.gearslip PROJECT_MEDIA allow",
|
||||
),
|
||||
Step(
|
||||
"Optional: keep notifications readable while music plays.",
|
||||
"Android treats sending audio to the car like sharing your screen, and hides what your " +
|
||||
"notifications say. Run this command too, and Gearslip switches that off only while " +
|
||||
"music is going to the car, then puts it back.",
|
||||
command = "adb shell pm grant app.seb3thehacker.gearslip android.permission.WRITE_SECURE_SETTINGS",
|
||||
),
|
||||
Step("Plug the phone into the car.", "Gearslip opens by itself when the head unit connects."),
|
||||
)
|
||||
|
||||
/**
|
||||
* What to do before the first drive, in order. Kept in a narrow card with room at the sides so
|
||||
* the lines stay short enough to read at a glance.
|
||||
* A command to run on a computer, shown so it can be copied rather than retyped. Tapping it puts
|
||||
* it on the clipboard, which is the only way it reaches the computer from here anyway.
|
||||
*/
|
||||
@Composable
|
||||
private fun CopyableCommand(command: String) {
|
||||
val clipboard = LocalClipboardManager.current
|
||||
val context = LocalContext.current
|
||||
Surface(
|
||||
color = MaterialTheme.colorScheme.surfaceContainerHighest,
|
||||
shape = MaterialTheme.shapes.small,
|
||||
onClick = {
|
||||
clipboard.setText(AnnotatedString(command))
|
||||
Toast.makeText(context, "Command copied", Toast.LENGTH_SHORT).show()
|
||||
},
|
||||
modifier = Modifier.fillMaxWidth().padding(top = 4.dp),
|
||||
) {
|
||||
Column(Modifier.padding(horizontal = 12.dp, vertical = 10.dp), verticalArrangement = Arrangement.spacedBy(6.dp)) {
|
||||
Text(
|
||||
command,
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
fontFamily = FontFamily.Monospace,
|
||||
color = MaterialTheme.colorScheme.onSurface,
|
||||
)
|
||||
Text(
|
||||
"Tap to copy",
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
color = MaterialTheme.colorScheme.primary,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** What to do before the first drive, in order. The same width as the status card above it. */
|
||||
@Composable
|
||||
private fun SetupSteps() {
|
||||
Box(Modifier.fillMaxWidth(), contentAlignment = Alignment.Center) {
|
||||
Surface(
|
||||
color = MaterialTheme.colorScheme.surfaceContainerLow,
|
||||
shape = MaterialTheme.shapes.extraLarge,
|
||||
modifier = Modifier.widthIn(max = 560.dp).padding(horizontal = 16.dp),
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
) {
|
||||
Column(Modifier.padding(horizontal = 24.dp, vertical = 22.dp), verticalArrangement = Arrangement.spacedBy(18.dp)) {
|
||||
Text(
|
||||
@@ -229,6 +286,7 @@ private fun SetupSteps() {
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
step.command?.let { CopyableCommand(it) }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user