wireless: connect by itself when a car Gearslip has connected to before comes up over Bluetooth

This commit is contained in:
Seb3thehacker committed 2026-10-22 03:24:43 -07:00
1 parent 6945f6755e
commit 8fbd1701b1
6 files changed
+174

No files matched your search

@@ -117,6 +117,17 @@ class GearslipActivity : ComponentActivity(), Projection {
}
}
/** Android's one-time "Allow" for a car to connect by itself; see [WirelessAuto]. */
private var approvingCar: String? = null
private val carApproval = registerForActivityResult(
androidx.activity.result.contract.ActivityResultContracts.StartIntentSenderForResult(),
) { result ->
val address = approvingCar ?: return@registerForActivityResult
approvingCar = null
if (result.resultCode == RESULT_OK) app.seb3thehacker.gearslip.wireless.WirelessAuto.approved(this, address)
else GearslipLog.w("auto-connect: declined in Android's dialog")
}
/** Setting Gearslip as the phone's caller ID and spam app - what lets it decline a ringing call. */
private val callScreeningRole = registerForActivityResult(StartActivityForResult()) { result ->
if (result.resultCode != RESULT_OK) GearslipLog.w("calls: call screening role declined")
@@ -138,6 +149,12 @@ class GearslipActivity : ComponentActivity(), Projection {
app.seb3thehacker.gearslip.wireless.WirelessLink.onConnected = { socket ->
runOnUiThread { startWireless(socket) }
}
app.seb3thehacker.gearslip.wireless.WirelessAuto.showApproval = { chooser, address ->
runOnUiThread {
approvingCar = address
carApproval.launch(androidx.activity.result.IntentSenderRequest.Builder(chooser).build())
}
}
CarSettings.init(this)
AppSettings.applyGearslipEnabled(this)
// A capture that died without tidying up would leave notifications exposed to every
@@ -216,6 +233,7 @@ class GearslipActivity : ComponentActivity(), Projection {
override fun onDestroy() {
super.onDestroy()
app.seb3thehacker.gearslip.wireless.WirelessLink.onConnected = null
app.seb3thehacker.gearslip.wireless.WirelessAuto.showApproval = null
runCatching { unregisterReceiver(detachReceiver) }
PhoneMirror.onStartRequested = null
PhoneMirror.onTokenReady = null
@@ -304,6 +322,10 @@ class GearslipActivity : ComponentActivity(), Projection {
}
private fun handleIntent(intent: Intent?) {
if (intent?.action == app.seb3thehacker.gearslip.wireless.WirelessAuto.ACTION_CAR_NEARBY) {
connectToNearbyCar(intent.getStringExtra(app.seb3thehacker.gearslip.wireless.WirelessAuto.EXTRA_ADDRESS))
return
}
if (intent?.action != UsbManager.ACTION_USB_ACCESSORY_ATTACHED) return
// Switched off in Settings: the system shouldn't route the car here at all, but if it
// does, leave the accessory alone.
@@ -321,6 +343,23 @@ class GearslipActivity : ComponentActivity(), Projection {
start(accessory)
}
/** A known car came up over Bluetooth: connect wirelessly, unless USB or another try is already on it. */
private fun connectToNearbyCar(address: String?) {
if (address == null || !AppSettings.gearslipEnabled(this)) return
if (descriptor != null) {
GearslipLog.i("auto-connect: the car is on USB already - staying on USB")
return
}
if (runner != null || app.seb3thehacker.gearslip.wireless.WirelessLink.busy) return
if (checkSelfPermission(android.Manifest.permission.BLUETOOTH_CONNECT) != android.content.pm.PackageManager.PERMISSION_GRANTED) {
GearslipLog.w("auto-connect: needs the Nearby devices permission")
return
}
val adapter = getSystemService(android.bluetooth.BluetoothManager::class.java)?.adapter ?: return
val device = runCatching { adapter.getRemoteDevice(address) }.getOrNull() ?: return
app.seb3thehacker.gearslip.wireless.WirelessLink.start(this, device)
}
private fun start(accessory: UsbAccessory) {
// A fresh ATTACHED intent means the system wants a new session, whatever state the old
// one is in - closing first guarantees openAccessory() below never fights a descriptor
@@ -109,6 +109,8 @@ internal fun WirelessButton() {
.clickable {
devices = null
WirelessLink.start(context, device)
// Next time this car connects, Gearslip does this by itself.
app.seb3thehacker.gearslip.wireless.WirelessAuto.connectedByHand(context, device)
}
.padding(vertical = 14.dp),
)
@@ -0,0 +1,15 @@
package app.seb3thehacker.gearslip.wireless
import android.companion.CompanionDeviceService
/** Android calls this when a car approved in [WirelessAuto] connects over Bluetooth. */
class CarPresenceService : CompanionDeviceService() {
@Deprecated("Deprecated in Java")
override fun onDeviceAppeared(address: String) {
WirelessAuto.carAppeared(applicationContext, address)
}
@Deprecated("Deprecated in Java")
override fun onDeviceDisappeared(address: String) {}
}
@@ -0,0 +1,102 @@
package app.seb3thehacker.gearslip.wireless
import android.annotation.SuppressLint
import android.bluetooth.BluetoothDevice
import android.companion.AssociationRequest
import android.companion.BluetoothDeviceFilter
import android.companion.CompanionDeviceManager
import android.content.Context
import android.content.Intent
import android.content.IntentSender
import android.os.Handler
import android.os.Looper
import app.seb3thehacker.gearslip.GearslipLog
import app.seb3thehacker.gearslip.GearslipActivity
/**
* Starts wireless Android Auto by itself when a car Gearslip has connected to before comes up
* over Bluetooth.
*
* Android only lets an app wake up and open its screen from the background for a device it
* manages as a companion, so the first wireless connection to a car also asks Android to make
* that car Gearslip's companion. Android shows its own one-time "Allow" for it; after that it
* tells [CarPresenceService] whenever the car connects, and Gearslip connects to it.
*/
object WirelessAuto {
/** Action on the activity's intent when the car came up on its own. */
const val ACTION_CAR_NEARBY = "app.seb3thehacker.gearslip.CAR_NEARBY"
const val EXTRA_ADDRESS = "address"
private const val PREFS = "gearslip_wireless_auto"
private val log = GearslipLog.tagged("AAW")
/** Set by the activity: shows Android's companion dialog, which only an activity can do. */
@Volatile var showApproval: ((IntentSender, String) -> Unit)? = null
/**
* Called whenever the driver connects to [device] by hand. Asks Android once per car; a car
* already approved only has its presence watched again, in case Android forgot.
*/
@SuppressLint("MissingPermission")
fun connectedByHand(context: Context, device: BluetoothDevice) {
val manager = context.getSystemService(CompanionDeviceManager::class.java) ?: return
val address = device.address
if (isCompanion(manager, address)) {
watch(context, address)
return
}
val prefs = context.getSharedPreferences(PREFS, Context.MODE_PRIVATE)
if (prefs.getBoolean(address, false)) return
prefs.edit().putBoolean(address, true).apply()
val request = AssociationRequest.Builder()
.addDeviceFilter(BluetoothDeviceFilter.Builder().setAddress(address).build())
.setSingleDevice(true)
.build()
log.i("auto-connect: asking Android to let Gearslip watch for ${WirelessLink.name(device)}")
@Suppress("DEPRECATION")
manager.associate(request, object : CompanionDeviceManager.Callback() {
@Deprecated("Deprecated in Java")
override fun onDeviceFound(chooser: IntentSender) {
showApproval?.invoke(chooser, address) ?: log.w("auto-connect: no screen to ask on")
}
override fun onFailure(error: CharSequence?) {
log.w("auto-connect: Android wouldn't ask ($error)")
}
}, Handler(Looper.getMainLooper()))
}
/** The driver allowed it in Android's dialog. */
fun approved(context: Context, address: String) {
log.i("auto-connect: allowed")
watch(context, address)
}
@SuppressLint("MissingPermission")
private fun watch(context: Context, address: String) {
val manager = context.getSystemService(CompanionDeviceManager::class.java) ?: return
@Suppress("DEPRECATION")
runCatching { manager.startObservingDevicePresence(address) }
.onFailure { log.w("auto-connect: couldn't watch for the car (${it.message})") }
}
@Suppress("DEPRECATION")
private fun isCompanion(manager: CompanionDeviceManager, address: String): Boolean =
runCatching { manager.associations.any { it.equals(address, ignoreCase = true) } }.getOrDefault(false)
/** From [CarPresenceService]: the car is here, so bring Gearslip up to connect to it. */
fun carAppeared(context: Context, address: String) {
log.i("auto-connect: a car Gearslip knows connected over Bluetooth")
runCatching {
context.startActivity(
Intent(context, GearslipActivity::class.java)
.setAction(ACTION_CAR_NEARBY)
.putExtra(EXTRA_ADDRESS, address)
.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK),
)
}.onFailure { log.w("auto-connect: couldn't open Gearslip (${it.message})") }
}
}
@@ -82,6 +82,9 @@ object WirelessLink {
@Volatile private var tcp: Socket? = null
@Volatile private var network: ConnectivityManager.NetworkCallback? = null
@Volatile private var worker: Thread? = null
/** A start-up or a session's Bluetooth channel is under way. */
val busy: Boolean get() = worker?.isAlive == true
/** Set when Android drops the car's network mid-try, so the rest don't bind to a dead one. */
@Volatile private var lostNetwork: Network? = null
/** Bumped on each Wi-Fi join and on stop, so an older run of TCP tries ends instead of racing the new one. */