From 73ffa7925e33ba73560ac8fcce1da10d30e6f5e9 Mon Sep 17 00:00:00 2001 From: Davide-Resigotti Date: Fri, 6 Nov 2026 14:08:06 +0100 Subject: [PATCH] A car that keeps its screen until it knows the phone hears its Bluetooth address The 2020 Civic (issue #43) answers every request for its screen with its own, and waits to hear which phone is connected over the car's Bluetooth channel. Gearslip never told it: CarBluetooth was only set up once the car screen drew, so on a car that never shows it the typed address never left the phone. It's now set up when Gearslip starts. Once the car hears the address it sends Play while still on its own screen, and reaching for the media app before the car UI exists ended the session; such keys are now logged and ignored. The second request for the screen now runs whether or not the media channel is open, and a car that keeps its screen gets the Bluetooth channel opened, and pairing started, as a real phone does. When the car answers there, it is asked for its screen again. --- .../gearslip/GearslipActivity.kt | 3 + .../seb3thehacker/gearslip/GearslipRunner.kt | 21 +++++- .../gearslip/car/CarBluetooth.kt | 67 ++++++++++++++++--- .../app/seb3thehacker/gearslip/car/CarKeys.kt | 25 +++++-- 4 files changed, 98 insertions(+), 18 deletions(-) diff --git a/gearslip/src/main/java/app/seb3thehacker/gearslip/GearslipActivity.kt b/gearslip/src/main/java/app/seb3thehacker/gearslip/GearslipActivity.kt index 3885769..be19824 100644 --- a/gearslip/src/main/java/app/seb3thehacker/gearslip/GearslipActivity.kt +++ b/gearslip/src/main/java/app/seb3thehacker/gearslip/GearslipActivity.kt @@ -159,6 +159,9 @@ class GearslipActivity : ComponentActivity(), Projection { } } CarSettings.init(this) + // Before any car connects: CarServices only starts once the car shows Gearslip's screen, + // and a car that waits to know the phone before it shows it would never hear (issue #43). + app.seb3thehacker.gearslip.car.CarBluetooth.init(this) AppSettings.applyGearslipEnabled(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. diff --git a/gearslip/src/main/java/app/seb3thehacker/gearslip/GearslipRunner.kt b/gearslip/src/main/java/app/seb3thehacker/gearslip/GearslipRunner.kt index f18e867..4bac3bb 100644 --- a/gearslip/src/main/java/app/seb3thehacker/gearslip/GearslipRunner.kt +++ b/gearslip/src/main/java/app/seb3thehacker/gearslip/GearslipRunner.kt @@ -85,6 +85,8 @@ class GearslipRunner( private var selectedConfigIndex: Int? = null private var vehicleProfile: VehicleProfile? = null @Volatile private var videoStarted = false + /** The car's screen has been asked for once, so asking again is in order. */ + @Volatile private var focusAsked = false private var videoSource: VideoSource? = null /** Between the car UI and the encoder when the car squashes the frame (see [screenShape]). */ @@ -773,6 +775,13 @@ class GearslipRunner( bluetooth, open = { openChannel(bluetooth.serviceId) }, send = { id, body -> send(id, body, encrypted = true, channel = bluetooth.serviceId) }, + // A car keeping its screen until it knows the phone may give it up now; ask again. + answered = { + if (running && focusAsked && !videoStarted) { + log.i("the car answered on Bluetooth while keeping its screen - asking for it again") + requestProjectedFocus(withDisplay = true) + } + }, ) } @@ -1068,7 +1077,7 @@ class GearslipRunner( "(head unit offered ${offered.size}), max_unacked=$maxUnacked", ) requestProjectedFocus() - if (CarAudio.openSink != null) watchForWithheldScreen() + watchForWithheldScreen() } MSG_VIDEO_FOCUS_NOTIFICATION -> { @@ -1361,6 +1370,7 @@ class GearslipRunner( * the field is deprecated, but a car older than that may ignore a request without it. */ private fun requestProjectedFocus(withDisplay: Boolean = false) { + focusAsked = true // VideoFocusRequestNotification { disp_channel_id = 1; mode = 2; reason = 3 }. Reason 1 // means "the phone's screen is off", which a 2020 Civic may have taken as a reason to // keep its own screen (issue #43); 0 is UNKNOWN. @@ -1380,6 +1390,10 @@ class GearslipRunner( * straight over keep their own source as before. * The 2017 Pilot (protocol 1.0) still kept its screen with the channel open, so the second * request also names the display's channel, as cars of the Pilot's age may need. + * This runs whether or not the media channel was open: a 2020 Civic with music through + * Gearslip opened it at connect, so on 0.3.40 it never heard the second request (issue #43). + * The car also hears which phone this is over Bluetooth (see [CarBluetooth.onScreenWithheld]), + * which Hondas may wait for; its answer asks for the screen once more. */ private fun watchForWithheldScreen() { Thread({ @@ -1389,11 +1403,12 @@ class GearslipRunner( if (open != null) { log.w("no screen from the car after ${WITHHELD_SCREEN_MS / 1000}s - opening the media channel and asking again") open() - // Give the car the channel's open response before it hears the request again. - Thread.sleep(1_000) } else { log.w("no screen from the car after ${WITHHELD_SCREEN_MS / 1000}s - asking again") } + val toldBluetooth = CarBluetooth.onScreenWithheld() + // Give the car the channels' open responses before it hears the request again. + if (open != null || toldBluetooth) Thread.sleep(1_000) if (running && !videoStarted) requestProjectedFocus(withDisplay = true) }, "gearslip-focus-fallback").apply { isDaemon = true }.start() } diff --git a/gearslip/src/main/java/app/seb3thehacker/gearslip/car/CarBluetooth.kt b/gearslip/src/main/java/app/seb3thehacker/gearslip/car/CarBluetooth.kt index daacc54..5a434f0 100644 --- a/gearslip/src/main/java/app/seb3thehacker/gearslip/car/CarBluetooth.kt +++ b/gearslip/src/main/java/app/seb3thehacker/gearslip/car/CarBluetooth.kt @@ -57,11 +57,12 @@ object CarBluetooth { private val main = Handler(Looper.getMainLooper()) private var appContext: Context? = null - /** This session's car: its channel, and how to open and write to it. */ + /** This session's car: its channel, how to open and write to it, and who hears its answers. */ private class Car( val service: ServiceDiscovery.BluetoothService, val open: () -> Unit, val send: (messageId: Int, body: ByteArray) -> Unit, + val answered: () -> Unit, ) @Volatile private var car: Car? = null @@ -104,13 +105,16 @@ object CarBluetooth { _addressWanted.value = null } - /** Once per car: it waits on the phone, and the car screen says so once. */ + /** + * Once per car: it waits on the phone, and the car screen says so once. [again] asks a car + * that was asked before, for one that keeps its screen until it knows the phone. + */ @SuppressLint("MissingPermission") - private fun askForAddress(device: BluetoothDevice, carAddress: String) { + private fun askForAddress(device: BluetoothDevice, carAddress: String, again: Boolean = false) { val context = appContext ?: return val asked = AppSettings.getString(context, KEY_ADDRESS_ASKED, "").split(",").filter { it.isNotEmpty() } - if (hash(carAddress) in asked) return - AppSettings.putString(context, KEY_ADDRESS_ASKED, (asked + hash(carAddress)).joinToString(",")) + if (hash(carAddress) in asked && !again) return + AppSettings.putString(context, KEY_ADDRESS_ASKED, (asked + hash(carAddress)).distinct().joinToString(",")) val name = runCatching { device.alias ?: device.name }.getOrNull()?.takeIf { it.isNotBlank() } ?: context.getString(R.string.bt_address_your_car) AppSettings.putString(context, KEY_ADDRESS_ASK, name) @@ -119,13 +123,17 @@ object CarBluetooth { main.post { CarToasts.show(this, context.getString(R.string.bt_address_car_note), CarToast.LENGTH_LONG) } } - /** Called once the car lists its services. Asks on the car screen only if there's pairing to do. */ + /** + * Called once the car lists its services. Asks on the car screen only if there's pairing to do. + * [answered] runs when the car answers a pairing request or the pairing finishes. + */ fun onCarConnected( service: ServiceDiscovery.BluetoothService, open: () -> Unit, send: (messageId: Int, body: ByteArray) -> Unit, + answered: () -> Unit = {}, ) { - car = Car(service, open, send) + car = Car(service, open, send, answered) bondStarted = false announcing = false log.i("the car offers Bluetooth pairing, methods=${service.pairingMethods}") @@ -159,6 +167,40 @@ object CarBluetooth { c.open() } + /** + * The car is keeping its own screen: do what Android Auto does on every connection, and tell + * it over Bluetooth which phone this is. Every Honda on file keeps its screen (a 2017 Pilot, a + * 2017 and a 2020 Civic; issues #43 and #57), and in each log the car's Bluetooth channel was + * never opened. Honda starts Android Auto with what its manuals call auto pairing, which runs + * on that channel, and a 2021 Bolt kept hanging up until it heard the phone's address (#18). + * + * - Paired, with the address typed in: the car already heard it at connect. + * - Paired, no address: open the channel, and ask for the address on the phone again. + * - Not paired: the Pair card waits on the car's screen, which this car won't give up, so + * pair as if the driver had tapped it. Android's pairing dialog still asks on the phone. + * + * True when the car was sent something it may answer. + */ + fun onScreenWithheld(): Boolean { + val c = car ?: return false + if (pairing || announcing) return false + val device = device() ?: return false + return when { + device.bondState == BluetoothDevice.BOND_BONDED -> { + askForAddress(device, c.service.carAddress, again = true) + announce("the car is keeping its screen") + true + } + device.bondState != BluetoothDevice.BOND_NONE -> false + declined(c.service.carAddress) -> false + else -> { + log.i("the car is keeping its screen and isn't paired with this phone - pairing, as Android Auto does") + main.post { pair() } + true + } + } + } + fun onCarDisconnected() { car = null pairing = false @@ -226,6 +268,7 @@ object CarBluetooth { MSG_PAIRING_RESPONSE -> { val fields = Wire.fields(body) log.i("<- PairingResponse status=${Wire.varint(fields, 1)} already_paired=${Wire.varint(fields, 2) == 1L}") + c.answered() if (!pairing) return // Some cars start pairing themselves once they answer; give them a moment first. main.postDelayed({ startBond("the car answered") }, CAR_BOND_WAIT_MS) @@ -260,7 +303,10 @@ object CarBluetooth { log.i("bond state $previous -> $state") when { state == BluetoothDevice.BOND_BONDING -> bondStarted = true - state == BluetoothDevice.BOND_BONDED -> finish(R.string.bt_paired) + state == BluetoothDevice.BOND_BONDED -> { + finish(R.string.bt_paired) + car?.answered?.invoke() + } previous == BluetoothDevice.BOND_BONDING -> finish(R.string.bt_pair_unfinished) } } @@ -285,7 +331,10 @@ object CarBluetooth { /** The car as a Bluetooth device, or null (and logged) when Gearslip can't reach Bluetooth. */ private fun device(): BluetoothDevice? { - val context = appContext ?: return null + val context = appContext ?: run { + log.w("can't check pairing: Bluetooth isn't set up yet") + return null + } val address = car?.service?.carAddress ?: return null if (context.checkSelfPermission(Manifest.permission.BLUETOOTH_CONNECT) != PackageManager.PERMISSION_GRANTED) { log.i("can't check pairing: the Bluetooth permission isn't granted") diff --git a/gearslip/src/main/java/app/seb3thehacker/gearslip/car/CarKeys.kt b/gearslip/src/main/java/app/seb3thehacker/gearslip/car/CarKeys.kt index 41cd676..7414c3b 100644 --- a/gearslip/src/main/java/app/seb3thehacker/gearslip/car/CarKeys.kt +++ b/gearslip/src/main/java/app/seb3thehacker/gearslip/car/CarKeys.kt @@ -58,6 +58,12 @@ object CarKeys { fun init(context: Context) { appContext = context.applicationContext } + /** The keys that act on the media app. */ + private val MEDIA_KEYS = setOf( + KeyEvent.KEYCODE_MEDIA_PLAY_PAUSE, KeyEvent.KEYCODE_MEDIA_PLAY, KeyEvent.KEYCODE_MEDIA_PAUSE, + KeyEvent.KEYCODE_MEDIA_STOP, KeyEvent.KEYCODE_MEDIA_NEXT, KeyEvent.KEYCODE_MEDIA_PREVIOUS, + ) + /** * Every key worth asking the head unit for. The binding request lists what the head unit * offered; when it offers nothing (some units leave the list empty and send keys anyway), @@ -87,13 +93,20 @@ object CarKeys { return true } if (!down) return keyCode in WANTED - val media = CarServices.media + // Null until the car shows Gearslip's screen. A 2020 Civic sends Play as soon as it hears + // the phone's Bluetooth address, while still on its own screen, and reaching for media + // then ended the session (issue #43). + val media = CarServices.mediaOrNull() + if (media == null && keyCode in MEDIA_KEYS) { + GearslipLog.i("keys: $keyCode before the car shows Gearslip - no media app to send it to yet") + return true + } when (keyCode) { - KeyEvent.KEYCODE_MEDIA_PLAY_PAUSE -> media.togglePlay() - KeyEvent.KEYCODE_MEDIA_PLAY -> if (!media.now.value.playing) media.togglePlay() - KeyEvent.KEYCODE_MEDIA_PAUSE, KeyEvent.KEYCODE_MEDIA_STOP -> if (media.now.value.playing) media.togglePlay() - KeyEvent.KEYCODE_MEDIA_NEXT -> media.next() - KeyEvent.KEYCODE_MEDIA_PREVIOUS -> media.previous() + KeyEvent.KEYCODE_MEDIA_PLAY_PAUSE -> media?.togglePlay() + KeyEvent.KEYCODE_MEDIA_PLAY -> if (media != null && !media.now.value.playing) media.togglePlay() + KeyEvent.KEYCODE_MEDIA_PAUSE, KeyEvent.KEYCODE_MEDIA_STOP -> if (media != null && media.now.value.playing) media.togglePlay() + KeyEvent.KEYCODE_MEDIA_NEXT -> media?.next() + KeyEvent.KEYCODE_MEDIA_PREVIOUS -> media?.previous() KeyEvent.KEYCODE_SEARCH, KeyEvent.KEYCODE_VOICE_ASSIST -> voice() KeyEvent.KEYCODE_CALL -> call() KeyEvent.KEYCODE_ENDCALL -> endCall()