Cars that turn Bluetooth music off get it through Gearslip, and Spotify's silence is explained

A Skoda, a VW, an Audi and a Bolt all dropped the phone's Bluetooth audio once Android Auto was
up. If Bluetooth audio to the car was connected when it showed Gearslip and drops after, that
car's music goes through Gearslip from then on, remembered per car, and the car screen says so.
Picking Bluetooth by hand clears what was learned. When capture stays silent while Spotify
plays, the car screen says Spotify doesn't let its sound be passed on.
This commit is contained in:
Seb3thehacker committed 2026-11-05 03:08:20 -08:00
1 parent 3e2c087483
commit 585b50ac72
10 files changed
+134 -18

No files matched your search

@@ -200,6 +200,7 @@ class GearslipRunner(
app.seb3thehacker.gearslip.car.CarData.clear()
CarMic.current = null
CarBluetooth.onCarDisconnected()
CarSettings.carDisconnected()
BluetoothAudioCheck.onCarDisconnected()
LocationCheck.onCarDisconnected()
mediaStatus?.stop()
@@ -627,7 +628,10 @@ class GearslipRunner(
onPausedForNow = { CarServices.pausePhoneMediaForNow() },
onGivenBack = { CarServices.resumePhoneMedia() },
)
if (CarSettings.pipeAudio.value) {
// A car known to switch Bluetooth music off gets it through Gearslip, whatever the setting.
CarSettings.carConnected(ServiceDiscovery.findBluetoothService(serviceDiscoveryResponse)?.carAddress)
if (CarSettings.pipesNow) {
if (!CarSettings.pipeAudio.value) log.i("this car turns Bluetooth music off, so music goes through Gearslip")
openChannel(media.serviceId)
} else {
// Many cars switch their source to Android Auto the moment this channel opens, which
@@ -157,6 +157,8 @@ class AudioCaptureService : Service() {
val startedAt = heardAt
var everHeard = false
var warnedSilent = false
var lastSound = startedAt
var silenceReported = false
try {
record.startRecording()
GearslipLog.i("audio: capturing media playback at ${rate}Hz x$channels")
@@ -174,6 +176,14 @@ class AudioCaptureService : Service() {
}
if (peak > 0) everHeard = true
val now = System.currentTimeMillis()
// A stretch of silence: CarAudio says why on the car if a blocking app is playing.
if (peak > 0) {
lastSound = now
silenceReported = false
} else if (!silenceReported && now - lastSound >= SILENCE_MS) {
silenceReported = true
CarAudio.silent(this@AudioCaptureService)
}
val level = peak / 32768f
if (now - heardAt >= LEVEL_LOG_MS) {
GearslipLog.i("audio: peak %.3f".format(level))
@@ -47,6 +47,9 @@ object CarAudio {
/** Opens the car's media channel, left closed while music went over Bluetooth; null once open. */
@Volatile var openSink: (() -> Unit)? = null
/** The car offers a music channel, open or waiting to be. */
val sinkOffered: Boolean get() = link != null || openSink != null
/** Rate and channel count the capture should use: the car's, or a sensible default. */
val sampleRate get() = link?.sampleRate ?: 48_000
val channels get() = link?.channels ?: 2
@@ -93,9 +96,29 @@ object CarAudio {
}
}
/**
* Capture has heard nothing for a while. If a media app that blocks capture is the one
* playing, the car screen says why the music is silent, once a drive: Spotify does this.
*/
internal fun silent(context: android.content.Context) = android.os.Handler(android.os.Looper.getMainLooper()).post {
if (toldBlocked) return@post
val media = app.seb3thehacker.gearslip.car.CarServices.mediaOrNull() ?: return@post
if (!media.now.value.playing) return@post
val name = BLOCKS_CAPTURE[media.packageName] ?: return@post
toldBlocked = true
GearslipLog.i("audio: $name is playing and capture hears nothing: it blocks other apps from playing its sound")
app.seb3thehacker.gearslip.car.CarToasts.show(this, context.getString(app.seb3thehacker.gearslip.R.string.car_audio_blocked, name), androidx.car.app.CarToast.LENGTH_LONG)
}
@Volatile private var toldBlocked = false
/** Media apps known to keep other apps from capturing their sound, by package. */
private val BLOCKS_CAPTURE = mapOf("com.spotify.music" to "Spotify")
/** The car went away. Everything is torn down; the next connection asks again. */
fun release() {
wanted = false
toldBlocked = false
AudioCaptureService.stop()
_capture.value = Capture.OFF
}
@@ -11,6 +11,7 @@ import android.content.Context
import android.content.Intent
import android.content.IntentFilter
import android.content.pm.PackageManager
import app.seb3thehacker.gearslip.R
import android.os.Handler
import android.os.Looper
import app.seb3thehacker.gearslip.GearslipLog
@@ -25,6 +26,11 @@ import kotlinx.coroutines.flow.StateFlow
*
* When the car named its Bluetooth address, only that device counts. Otherwise any audio
* connection does, since there's no telling which device is the car.
*
* Many cars switch Bluetooth music off while Android Auto runs and want it over Android Auto
* instead (a Skoda, a VW, an Audi, a Bolt). If the phone's Bluetooth audio to the car was up when
* the car showed Gearslip and drops after, that's what happened: this car's music goes through
* Gearslip from now on, and the car screen says so.
*/
object BluetoothAudioCheck {
@@ -41,6 +47,8 @@ object BluetoothAudioCheck {
private var appContext: Context? = null
private var armed = false
private var receiver: BroadcastReceiver? = null
/** Whether Bluetooth audio to the car was up when the car showed Gearslip; null until known. */
private var connectedAtStart: Boolean? = null
fun init(context: Context) {
appContext = context.applicationContext
@@ -50,11 +58,14 @@ object BluetoothAudioCheck {
fun onProjecting() = main.post {
if (armed) return@post
armed = true
connectedAtStart = null
audioToCar { connectedAtStart = it }
main.postDelayed(::start, WAIT_MS)
}
fun onCarDisconnected() = main.post {
armed = false
connectedAtStart = null
main.removeCallbacksAndMessages(null)
stopWatching()
_missing.value = false
@@ -97,36 +108,58 @@ object BluetoothAudioCheck {
private fun check() {
if (!armed) return
val context = appContext ?: return
if (CarSettings.pipeAudio.value) {
if (CarSettings.pipesNow) {
set(false, "audio goes through Gearslip")
return
}
if (!CarSettings.bluetoothWarning.value) {
set(false, "the warning is turned off")
return
audioToCar { ok ->
if (ok == null) return@audioToCar
if (!ok && connectedAtStart == true) return@audioToCar switchToGearslip()
if (!CarSettings.bluetoothWarning.value) {
set(false, "the warning is turned off")
return@audioToCar
}
set(!ok, if (ok) "connected to the car" else "not connected to the car")
}
}
/** The car dropped Bluetooth music once Gearslip was up: send this car's music through Gearslip. */
private fun switchToGearslip() {
val car = CarBluetooth.carAddress ?: return
val context = appContext ?: return
if (!app.seb3thehacker.gearslip.audio.CarAudio.sinkOffered) return
log.i("Bluetooth audio: the car turned it off with Gearslip running, so this car's music goes through Gearslip")
CarSettings.rememberAudioCar(car)
set(false, "audio goes through Gearslip")
app.seb3thehacker.gearslip.audio.CarAudio.request()
CarToasts.show(this, context.getString(R.string.car_audio_switched), androidx.car.app.CarToast.LENGTH_LONG)
}
/**
* Whether the phone's Bluetooth audio is connected to the car, on the main thread: null when
* that can't be told (no permission). Bluetooth switched off counts as not connected.
*/
@SuppressLint("MissingPermission")
private fun audioToCar(result: (Boolean?) -> Unit) {
val context = appContext ?: return result(null)
if (context.checkSelfPermission(Manifest.permission.BLUETOOTH_CONNECT) != PackageManager.PERMISSION_GRANTED) {
log.i("can't check the audio connection: the Bluetooth permission isn't granted")
return
}
val adapter = context.getSystemService(BluetoothManager::class.java)?.adapter ?: return
if (!adapter.isEnabled) {
set(true, "Bluetooth is off")
return
return result(null)
}
val adapter = context.getSystemService(BluetoothManager::class.java)?.adapter ?: return result(null)
if (!adapter.isEnabled) return result(false)
val car = CarBluetooth.carAddress?.uppercase()
adapter.getProfileProxy(context, object : BluetoothProfile.ServiceListener {
val asked = adapter.getProfileProxy(context, object : BluetoothProfile.ServiceListener {
override fun onServiceConnected(profile: Int, proxy: BluetoothProfile) {
val connected = runCatching { proxy.connectedDevices }.getOrDefault(emptyList())
adapter.closeProfileProxy(profile, proxy)
val ok = if (car != null) connected.any { it.address.uppercase() == car } else connected.isNotEmpty()
main.post {
set(!ok, if (ok) "connected to the car" else "not connected to the car")
}
main.post { result(ok) }
}
override fun onServiceDisconnected(profile: Int) {}
}, BluetoothProfile.A2DP)
if (!asked) result(null)
}
private fun set(missing: Boolean, why: String) {
@@ -38,7 +38,7 @@ import androidx.compose.ui.unit.dp
@Composable
internal fun BluetoothPairOverlay() {
val prompt by CarBluetooth.prompt.collectAsState()
val bluetoothAudio = !CarSettings.pipeAudio.collectAsState().value
val bluetoothAudio = !CarSettings.pipeAudio.collectAsState().value && !CarSettings.thisCarPipes.collectAsState().value
when (prompt) {
CarBluetooth.Prompt.NONE -> {
val missing by BluetoothAudioCheck.missing.collectAsState()
@@ -150,7 +150,7 @@ object CarServices {
media.connect(app)
// 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()
if (CarSettings.pipesNow) CarAudio.request()
}
/**
@@ -57,6 +57,7 @@ object CarSettings {
private const val KEY_OPEN = "car_open_on_connect"
private const val KEY_NIGHT = "car_night_mode"
private const val KEY_PIPE_AUDIO = "car_pipe_audio"
private const val KEY_AUDIO_CARS = "car_audio_through_gearslip_cars"
private const val KEY_SPEAK_DIRECTIONS = "car_speak_directions"
private const val KEY_DIRECTIONS_OVER_MUSIC = "car_directions_over_music"
private const val KEY_BLUETOOTH_WARNING = "car_bluetooth_warning"
@@ -269,6 +270,41 @@ private const val KEY_CLUSTER_VIDEO = "cluster_video_enabled"
*/
val pipeAudio: StateFlow<Boolean> = pipeAudioFlow
private val thisCarPipesFlow = MutableStateFlow(false)
/**
* The car connected now switches Bluetooth music off while Android Auto runs, so its music
* goes through Gearslip whatever [pipeAudio] says. Learned by [BluetoothAudioCheck] and
* remembered per car: a Skoda, a VW, an Audi and a Bolt all did this.
*/
val thisCarPipes: StateFlow<Boolean> = thisCarPipesFlow
/** Whether music goes through Gearslip in this car: the driver's choice, or what the car needs. */
val pipesNow: Boolean get() = pipeAudioFlow.value || thisCarPipesFlow.value
/** A car connected: whether it's one that needs music through Gearslip. Null for none known. */
fun carConnected(carAddress: String?) {
thisCarPipesFlow.value = carAddress != null && carKey(carAddress) in audioCars()
}
fun carDisconnected() {
thisCarPipesFlow.value = false
}
/** This car turned Bluetooth music off: from now on its music goes through Gearslip. */
fun rememberAudioCar(carAddress: String) {
AppSettings.putString(app, KEY_AUDIO_CARS, (audioCars() + carKey(carAddress)).distinct().joinToString(","))
thisCarPipesFlow.value = true
}
private fun audioCars(): List<String> =
AppSettings.getString(app, KEY_AUDIO_CARS, "").split(",").filter { it.isNotEmpty() }
/** Settings keep a short hash, not the car's address. */
private fun carKey(address: String): String =
java.security.MessageDigest.getInstance("SHA-256").digest(address.uppercase().toByteArray())
.take(8).joinToString("") { "%02x".format(it) }
/** Gearslip reads the map app's turns aloud on the car's directions channel; see DirectionsVoice. */
val speakDirections: StateFlow<Boolean> = speakDirectionsFlow
@@ -471,6 +507,11 @@ private const val KEY_CLUSTER_VIDEO = "cluster_video_enabled"
fun setPipeAudio(on: Boolean) {
// Saved even when unchanged, so picking the default still counts as a choice.
AppSettings.putString(app, KEY_PIPE_AUDIO, on.toString())
// Choosing Bluetooth by hand overrules what Gearslip learned about each car.
if (!on) {
AppSettings.putString(app, KEY_AUDIO_CARS, "")
thisCarPipesFlow.value = false
}
if (pipeAudioFlow.value == on) return
pipeAudioFlow.value = on
if (AppSettings.getString(app, KEY_SPEAK_DIRECTIONS, "") == "") speakDirectionsFlow.value = on
@@ -146,7 +146,7 @@ fun CarUi() {
// Ask for the phone's sound as soon as the car connects, not only when a media app opens:
// a video in the web browser or a map's voice needs it too. Asks once, after the car offers
// somewhere to play it.
LaunchedEffect(Unit) { if (CarSettings.pipeAudio.value) app.seb3thehacker.gearslip.audio.CarAudio.request() }
LaunchedEffect(Unit) { if (CarSettings.pipesNow) app.seb3thehacker.gearslip.audio.CarAudio.request() }
// A new version gets one card in the top right, a few seconds in and only while parked.
LaunchedEffect(Unit) { app.seb3thehacker.gearslip.update.UpdateChecker.onCarConnected(appContext) }
// The map follows the light outside, whatever the app's own theme is set to.
@@ -418,6 +418,9 @@ class CarMedia(private val context: Context) {
checkSearch()
}
/** The package of the app Gearslip controls now, or null. */
val packageName: String? get() = controller?.packageName
private var appResources: Pair<String, android.content.res.Resources?>? = null
/**
+2
View File
@@ -875,6 +875,8 @@
<string name="status_no_car_graphene">The phone is charging, but no car has answered. Check that USB-C port control, under Security &amp; privacy, allows data and isn\'t set to Charging-only.</string>
<string name="status_stuck_cable">The car hasn\'t shown Gearslip yet. Unplug the cable, wait a few seconds, and plug it back in. If it keeps happening, try another cable or USB port, and share the log in an issue.</string>
<string name="status_stuck_wireless">The car hasn\'t shown Gearslip yet. Turn the car off and on again, or connect with a cable. If it keeps happening, share the log in an issue.</string>
<string name="car_audio_switched">This car turns Bluetooth music off while Gearslip runs, so its music now goes through Gearslip. Allow the prompt on your phone.</string>
<string name="car_audio_blocked">%1$s doesn\'t let other apps play its sound, so it can\'t reach the car through Gearslip. Try another music app.</string>
<string name="bt_address_card_title">Help %1$s know this phone</string>
<string name="bt_address_card_body">Some cars stay connected only once they know the phone\'s Bluetooth address. Android keeps it from apps, so copy it from About phone. Press and hold the Bluetooth address there, tap Copy, and come back.</string>
<string name="bt_address_open">Open About phone</string>