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. */