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()