diff --git a/gearslip/src/main/AndroidManifest.xml b/gearslip/src/main/AndroidManifest.xml
index 2aa3ab1..c683230 100644
--- a/gearslip/src/main/AndroidManifest.xml
+++ b/gearslip/src/main/AndroidManifest.xml
@@ -25,6 +25,9 @@
hands over its Wi-Fi details; Gearslip then joins that network to reach the car.
-->
+
+
+
+
+
+
+
+
+
+
diff --git a/gearslip/src/main/java/app/seb3thehacker/gearslip/GearslipActivity.kt b/gearslip/src/main/java/app/seb3thehacker/gearslip/GearslipActivity.kt
index 2f8ee50..ca15dd6 100644
--- a/gearslip/src/main/java/app/seb3thehacker/gearslip/GearslipActivity.kt
+++ b/gearslip/src/main/java/app/seb3thehacker/gearslip/GearslipActivity.kt
@@ -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
diff --git a/gearslip/src/main/java/app/seb3thehacker/gearslip/ui/WirelessButton.kt b/gearslip/src/main/java/app/seb3thehacker/gearslip/ui/WirelessButton.kt
index 8e0c5e3..32e6d2b 100644
--- a/gearslip/src/main/java/app/seb3thehacker/gearslip/ui/WirelessButton.kt
+++ b/gearslip/src/main/java/app/seb3thehacker/gearslip/ui/WirelessButton.kt
@@ -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),
)
diff --git a/gearslip/src/main/java/app/seb3thehacker/gearslip/wireless/CarPresenceService.kt b/gearslip/src/main/java/app/seb3thehacker/gearslip/wireless/CarPresenceService.kt
new file mode 100644
index 0000000..cd2f045
--- /dev/null
+++ b/gearslip/src/main/java/app/seb3thehacker/gearslip/wireless/CarPresenceService.kt
@@ -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) {}
+}
diff --git a/gearslip/src/main/java/app/seb3thehacker/gearslip/wireless/WirelessAuto.kt b/gearslip/src/main/java/app/seb3thehacker/gearslip/wireless/WirelessAuto.kt
new file mode 100644
index 0000000..2bef63a
--- /dev/null
+++ b/gearslip/src/main/java/app/seb3thehacker/gearslip/wireless/WirelessAuto.kt
@@ -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})") }
+ }
+}
diff --git a/gearslip/src/main/java/app/seb3thehacker/gearslip/wireless/WirelessLink.kt b/gearslip/src/main/java/app/seb3thehacker/gearslip/wireless/WirelessLink.kt
index 3b69035..521dd3e 100644
--- a/gearslip/src/main/java/app/seb3thehacker/gearslip/wireless/WirelessLink.kt
+++ b/gearslip/src/main/java/app/seb3thehacker/gearslip/wireless/WirelessLink.kt
@@ -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. */