Merge pull request #86 from davideresigotti/honda-civic-screen

Honda Civic: tell the car which phone this is, so it gives up its screen
This commit is contained in:
Seb3thehacker authored and GitHub committed 2026-11-06 12:02:25 -08:00
commit f0871eb4b1
4 files changed
+98 -18

No files matched your search

@@ -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.
@@ -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()
}
@@ -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")
@@ -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()