From 39e1a5ee097862568027746a594855713c2f2120 Mon Sep 17 00:00:00 2001 From: Seb3thehacker Date: Wed, 7 Oct 2026 18:45:57 -0700 Subject: [PATCH] wireless func added (testing) --- gearslip/src/main/AndroidManifest.xml | 14 + .../gearslip/GearslipActivity.kt | 40 ++- .../seb3thehacker/gearslip/ui/HomeScreen.kt | 1 + .../seb3thehacker/gearslip/ui/WirelessCard.kt | 98 +++++++ .../gearslip/wireless/WirelessLink.kt | 271 ++++++++++++++++++ 5 files changed, 421 insertions(+), 3 deletions(-) create mode 100644 gearslip/src/main/java/app/seb3thehacker/gearslip/ui/WirelessCard.kt create mode 100644 gearslip/src/main/java/app/seb3thehacker/gearslip/wireless/WirelessLink.kt diff --git a/gearslip/src/main/AndroidManifest.xml b/gearslip/src/main/AndroidManifest.xml index 4dc1ede..7ed34c4 100644 --- a/gearslip/src/main/AndroidManifest.xml +++ b/gearslip/src/main/AndroidManifest.xml @@ -19,6 +19,20 @@ + + + + + + + + + diff --git a/gearslip/src/main/java/app/seb3thehacker/gearslip/GearslipActivity.kt b/gearslip/src/main/java/app/seb3thehacker/gearslip/GearslipActivity.kt index 8fe7da3..f906146 100644 --- a/gearslip/src/main/java/app/seb3thehacker/gearslip/GearslipActivity.kt +++ b/gearslip/src/main/java/app/seb3thehacker/gearslip/GearslipActivity.kt @@ -45,6 +45,8 @@ class GearslipActivity : ComponentActivity(), Projection { private var descriptor: ParcelFileDescriptor? = null + /** The wireless session's socket, when the car came over Wi-Fi instead of USB. */ + private var wirelessSocket: java.net.Socket? = null private var runner: GearslipRunner? = null private var screenProjector: ScreenProjector? = null private var worker: Thread? = null @@ -92,7 +94,8 @@ class GearslipActivity : ComponentActivity(), Projection { override fun onReceive(context: Context?, intent: Intent?) { if (intent?.action == UsbManager.ACTION_USB_ACCESSORY_DETACHED) { GearslipLog.w("accessory detached") - closeAccessory() + // A wireless session has no accessory to lose; leave it running. + if (wirelessSocket == null) closeAccessory() } } } @@ -115,6 +118,9 @@ class GearslipActivity : ComponentActivity(), Projection { GearslipLog.init(this) GearslipLog.installCrashHandler() GearslipLog.i("Gearslip ready") + app.seb3thehacker.gearslip.wireless.WirelessLink.onConnected = { socket -> + runOnUiThread { startWireless(socket) } + } CarSettings.init(this) AppSettings.applyGearslipEnabled(this) // A capture that died without tidying up would leave notifications exposed to every @@ -127,7 +133,7 @@ class GearslipActivity : ComponentActivity(), Projection { setContent { GearslipTheme { - GearslipApp(onDisconnect = ::closeAccessory, onRequestCallScreening = ::requestCallScreeningRole) + GearslipApp(onDisconnect = { closeAccessory() }, onRequestCallScreening = ::requestCallScreeningRole) } } @@ -161,6 +167,7 @@ class GearslipActivity : ComponentActivity(), Projection { override fun onDestroy() { super.onDestroy() + app.seb3thehacker.gearslip.wireless.WirelessLink.onConnected = null runCatching { unregisterReceiver(detachReceiver) } PhoneMirror.onStartRequested = null PhoneMirror.onTokenReady = null @@ -296,16 +303,43 @@ class GearslipActivity : ComponentActivity(), Projection { worker = Thread { runner.run() }.apply { name = "gearslip"; start() } } + /** A wireless session: the same runner, on the socket [WirelessLink] opened to the car. */ + private fun startWireless(socket: java.net.Socket) { + closeAccessory(stopWireless = false) + if (!AppSettings.gearslipEnabled(this)) { + GearslipLog.i("wireless: Gearslip is switched off - not connecting") + runCatching { socket.close() } + return + } + GearslipLog.i("wireless: starting a session over Wi-Fi to ${socket.inetAddress?.hostAddress}:${socket.port}") + wirelessSocket = socket + val runner = GearslipRunner( + input = socket.getInputStream(), + output = socket.getOutputStream(), + identityProvider = { CertProvider.load(this) }, + projection = this, + vehicleProfileFor = { info -> VehicleProfiles.find(VehicleProfiles.load(this), info) }, + ) + this.runner = runner + worker = Thread { runner.run() }.apply { name = "gearslip"; start() } + } + private fun describe(accessory: UsbAccessory) { GearslipLog.i("accessory: manufacturer=${accessory.manufacturer} model=${accessory.model}") GearslipLog.i(" version=${accessory.version} description=${accessory.description}") } - private fun closeAccessory() { + private fun closeAccessory() = closeAccessory(stopWireless = true) + + /** Ends the session, wired or wireless. [stopWireless] also drops the car's Bluetooth and Wi-Fi. */ + private fun closeAccessory(stopWireless: Boolean) { runner?.stop() SessionReport.print() runCatching { descriptor?.close() } descriptor = null + runCatching { wirelessSocket?.close() } + wirelessSocket = null + if (stopWireless) app.seb3thehacker.gearslip.wireless.WirelessLink.stop(this) runner = null } diff --git a/gearslip/src/main/java/app/seb3thehacker/gearslip/ui/HomeScreen.kt b/gearslip/src/main/java/app/seb3thehacker/gearslip/ui/HomeScreen.kt index a13d01f..767ab57 100644 --- a/gearslip/src/main/java/app/seb3thehacker/gearslip/ui/HomeScreen.kt +++ b/gearslip/src/main/java/app/seb3thehacker/gearslip/ui/HomeScreen.kt @@ -91,6 +91,7 @@ fun HomeScreen( Spacer(Modifier.height(16.dp)) StatusCard(status) Spacer(Modifier.height(16.dp)) + if (status.phase != SessionStatus.Phase.PROJECTING) WirelessCard() } // Sits at the bottom, where a thumb reaches it. diff --git a/gearslip/src/main/java/app/seb3thehacker/gearslip/ui/WirelessCard.kt b/gearslip/src/main/java/app/seb3thehacker/gearslip/ui/WirelessCard.kt new file mode 100644 index 0000000..6e5f6ff --- /dev/null +++ b/gearslip/src/main/java/app/seb3thehacker/gearslip/ui/WirelessCard.kt @@ -0,0 +1,98 @@ +package app.seb3thehacker.gearslip.ui + +import android.Manifest +import android.bluetooth.BluetoothDevice +import android.content.pm.PackageManager +import android.os.Build +import androidx.activity.compose.rememberLauncherForActivityResult +import androidx.activity.result.contract.ActivityResultContracts +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.verticalScroll +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Info +import androidx.compose.material3.AlertDialog +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.unit.dp +import androidx.core.content.ContextCompat +import app.seb3thehacker.gearslip.wireless.WirelessLink + +/** What the wireless start-up needs: Bluetooth to reach the car, nearby Wi-Fi to join its network. */ +private val WIRELESS_PERMISSIONS = buildList { + add(Manifest.permission.BLUETOOTH_CONNECT) + if (Build.VERSION.SDK_INT >= 33) add(Manifest.permission.NEARBY_WIFI_DEVICES) + add(Manifest.permission.ACCESS_FINE_LOCATION) +}.toTypedArray() + +/** + * Test entry point for wireless Android Auto: pick the paired car, and Gearslip connects to it + * over Bluetooth and Wi-Fi. Automatic start comes later. + */ +@Composable +internal fun WirelessCard() { + val context = LocalContext.current + var devices by remember { mutableStateOf?>(null) } + var denied by remember { mutableStateOf(false) } + fun granted() = WIRELESS_PERMISSIONS.all { + ContextCompat.checkSelfPermission(context, it) == PackageManager.PERMISSION_GRANTED + } + val ask = rememberLauncherForActivityResult(ActivityResultContracts.RequestMultiplePermissions()) { + if (granted()) devices = WirelessLink.pairedDevices(context) else denied = true + } + + SettingsCard { + SettingsRow( + "Connect wirelessly (test)", + Modifier.clickable { + denied = false + if (granted()) devices = WirelessLink.pairedDevices(context) else ask.launch(WIRELESS_PERMISSIONS) + }, + subtitle = if (denied) "Gearslip needs Bluetooth and nearby Wi-Fi access for this" + else "Pick your car from the phone's paired devices", + icon = Icons.Filled.Info, + ) + } + + devices?.let { list -> + AlertDialog( + onDismissRequest = { devices = null }, + title = { Text("Which car?") }, + text = { + Column(Modifier.verticalScroll(rememberScrollState())) { + if (list.isEmpty()) { + Text("No paired devices. Pair the phone with the car over Bluetooth first.") + } + list.forEach { device -> + Text( + WirelessLink.name(device), + style = MaterialTheme.typography.bodyLarge, + modifier = Modifier + .fillMaxWidth() + .clickable { + devices = null + WirelessLink.start(context, device) + } + .padding(vertical = 14.dp), + ) + } + } + }, + confirmButton = { TextButton(onClick = { devices = null }) { Text("Cancel") } }, + ) + } + Spacer(Modifier.height(16.dp)) +} diff --git a/gearslip/src/main/java/app/seb3thehacker/gearslip/wireless/WirelessLink.kt b/gearslip/src/main/java/app/seb3thehacker/gearslip/wireless/WirelessLink.kt new file mode 100644 index 0000000..06c7bee --- /dev/null +++ b/gearslip/src/main/java/app/seb3thehacker/gearslip/wireless/WirelessLink.kt @@ -0,0 +1,271 @@ +package app.seb3thehacker.gearslip.wireless + +import android.annotation.SuppressLint +import android.bluetooth.BluetoothDevice +import android.bluetooth.BluetoothManager +import android.bluetooth.BluetoothSocket +import android.content.Context +import android.net.ConnectivityManager +import android.net.MacAddress +import android.net.Network +import android.net.NetworkCapabilities +import android.net.NetworkRequest +import android.net.wifi.WifiNetworkSpecifier +import app.seb3thehacker.gearslip.GearslipLog +import app.seb3thehacker.gearslip.Protobuf +import app.seb3thehacker.gearslip.SessionStatus +import app.seb3thehacker.gearslip.Wire +import java.io.DataInputStream +import java.io.IOException +import java.io.OutputStream +import java.net.InetSocketAddress +import java.net.Socket +import java.util.UUID + +/** + * Wireless Android Auto, phone side: the start-up that hands Gearslip a socket instead of a USB + * accessory. Everything after it - versions, TLS, the certificate, channels, video - is the same + * protocol [app.seb3thehacker.gearslip.GearslipRunner] already speaks over USB. + * + * 1. Bluetooth: open RFCOMM to the car's Android Auto wireless service. The car must already be + * paired with the phone, as it usually is for calls. + * 2. Over that channel, framed as [u16 length][u16 message id][protobuf]: + * <- VersionRequest -> VersionResponse + * <- StartRequest {ip, port} -> InfoRequest + * <- InfoResponse {ssid, password, bssid, security} + * 3. Join the car's Wi-Fi as a local-only network, so the phone's internet stays on mobile data. + * -> StartResponse {status} -> ConnectionStatus {status} + * 4. TCP to {ip, port} over that network, and hand the socket to [onConnected]. + * + * The Bluetooth channel stays open for the whole drive: the car pings over it, and some cars end + * the session when it closes. Message layout from LIVI's head-unit side (livi-aa/src/wpp.rs) and + * aasdk's aaw protos. + */ +object WirelessLink { + + private val log = GearslipLog.tagged("AAW") + + /** The Android Auto wireless service every head unit publishes over Bluetooth. */ + val AA_UUID: UUID = UUID.fromString("4de17a00-52cb-11e6-bdf4-0800200c9a66") + + private const val MSG_START_REQUEST = 1 + private const val MSG_INFO_REQUEST = 2 + private const val MSG_INFO_RESPONSE = 3 + private const val MSG_VERSION_REQUEST = 4 + private const val MSG_VERSION_RESPONSE = 5 + private const val MSG_CONNECTION_STATUS = 6 + private const val MSG_START_RESPONSE = 7 + private const val MSG_PING = 8 + private const val MSG_PONG = 9 + + private const val TCP_TRIES = 5 + private const val WIFI_WAIT_MS = 60_000 + + /** Set by the activity: starts a session on the car's socket. */ + @Volatile var onConnected: ((Socket) -> Unit)? = null + + @Volatile private var rfcomm: BluetoothSocket? = null + @Volatile private var tcp: Socket? = null + @Volatile private var network: ConnectivityManager.NetworkCallback? = null + @Volatile private var worker: Thread? = null + private val writeLock = Any() + + /** Paired Bluetooth devices, cars or not: the driver picks theirs. Needs BLUETOOTH_CONNECT. */ + @SuppressLint("MissingPermission") + fun pairedDevices(context: Context): List = + context.getSystemService(BluetoothManager::class.java)?.adapter?.bondedDevices + ?.sortedBy { it.name ?: it.address } + .orEmpty() + + @SuppressLint("MissingPermission") + fun name(device: BluetoothDevice): String = device.name ?: device.address + + fun start(context: Context, device: BluetoothDevice) { + stop(context) + val app = context.applicationContext + worker = Thread({ run(app, device) }, "gearslip-wireless").apply { isDaemon = true; start() } + } + + /** Closes the Bluetooth channel, the socket and the Wi-Fi request. Safe to call any time. */ + fun stop(context: Context) { + worker?.interrupt() + worker = null + runCatching { rfcomm?.close() } + rfcomm = null + runCatching { tcp?.close() } + tcp = null + network?.let { cb -> + runCatching { context.getSystemService(ConnectivityManager::class.java).unregisterNetworkCallback(cb) } + } + network = null + } + + @SuppressLint("MissingPermission") + private fun run(context: Context, device: BluetoothDevice) { + val name = name(device) + log.i("--- wireless start-up with $name (${device.address}) ---") + SessionStatus.connecting("Bluetooth: reaching $name") + val socket = try { + context.getSystemService(BluetoothManager::class.java)?.adapter?.cancelDiscovery() + device.createRfcommSocketToServiceRecord(AA_UUID).also { it.connect() } + } catch (e: IOException) { + log.e("Bluetooth: could not open the Android Auto channel on $name", e) + SessionStatus.failed( + "Car didn't answer over Bluetooth", + "$name has no wireless Android Auto service, or it's turned off in the car. Check that " + + "the car is paired and wireless Android Auto is on.", + ) + return + } + rfcomm = socket + log.i("Bluetooth: channel open") + SessionStatus.connecting("Bluetooth: talking to $name") + + val input = DataInputStream(socket.inputStream) + val output = socket.outputStream + var target: Pair? = null + try { + while (!Thread.currentThread().isInterrupted) { + val length = input.readUnsignedShort() + val id = input.readUnsignedShort() + val body = ByteArray(length).also { input.readFully(it) } + when (id) { + MSG_VERSION_REQUEST -> { + log.i("<- VersionRequest") + log.hex(" version", body) + // The fields' meaning isn't known; answering with the car's own numbers + // has worked on LIVI. Field 4 is required, so it goes as 0. + val f = Wire.fields(body) + val reply = Protobuf.varintField(1, Wire.varint(f, 1) ?: 1) + + Protobuf.varintField(2, Wire.varint(f, 2) ?: 0) + + Protobuf.varintField(4, 0) + send(output, MSG_VERSION_RESPONSE, reply) + log.i("-> VersionResponse") + } + MSG_START_REQUEST -> { + val f = Wire.fields(body) + val ip = f.firstOrNull { it.number == 1 && it.wireType == 2 }?.bytes?.decodeToString().orEmpty() + val port = Wire.varint(f, 2)?.toInt() ?: 0 + log.i("<- StartRequest: projection at $ip:$port") + target = ip to port + send(output, MSG_INFO_REQUEST, ByteArray(0)) + log.i("-> InfoRequest") + } + MSG_INFO_RESPONSE -> { + val f = Wire.fields(body) + fun text(n: Int) = f.firstOrNull { it.number == n && it.wireType == 2 }?.bytes?.decodeToString().orEmpty() + val ssid = text(1) + val password = text(2) + val bssid = text(3) + val security = Wire.varint(f, 4) ?: -1 + log.i("<- InfoResponse: ssid=\"$ssid\" bssid=$bssid security=$security password=${if (password.isEmpty()) "none" else "(${password.length} chars)"}") + val (ip, port) = target ?: run { + log.w("InfoResponse before StartRequest: no address to connect to yet") + "" to 0 + } + joinWifi(context, output, name, ssid, password, bssid, ip, port) + } + MSG_PING -> send(output, MSG_PONG, body) + MSG_CONNECTION_STATUS, MSG_START_RESPONSE -> { + log.i("<- message $id: status=${Wire.varint(Wire.fields(body), if (id == MSG_START_RESPONSE) 3 else 1)}") + } + else -> { + log.i("<- unknown message $id (${body.size} bytes)") + log.hex(" msg", body) + } + } + } + } catch (e: IOException) { + if (rfcomm != null) log.w("Bluetooth: channel closed (${e.message})") + } + } + + private fun send(output: OutputStream, id: Int, body: ByteArray) { + val frame = ByteArray(4 + body.size) + frame[0] = (body.size shr 8).toByte(); frame[1] = body.size.toByte() + frame[2] = (id shr 8).toByte(); frame[3] = id.toByte() + body.copyInto(frame, 4) + synchronized(writeLock) { output.write(frame); output.flush() } + } + + /** + * Asks Android for the car's network. The first time, Android shows a "Connect to device" + * prompt on the phone; the driver has to accept it. + */ + private fun joinWifi( + context: Context, output: OutputStream, name: String, + ssid: String, password: String, bssid: String, ip: String, port: Int, + ) { + SessionStatus.connecting("Wi-Fi: joining $name's network") + val spec = WifiNetworkSpecifier.Builder().setSsid(ssid).apply { + runCatching { MacAddress.fromString(bssid) }.getOrNull() + ?.takeIf { it != MacAddress.fromString("00:00:00:00:00:00") } + ?.let { setBssid(it) } + if (password.isNotEmpty()) setWpa2Passphrase(password) + }.build() + val request = NetworkRequest.Builder() + .addTransportType(NetworkCapabilities.TRANSPORT_WIFI) + .removeCapability(NetworkCapabilities.NET_CAPABILITY_INTERNET) + .setNetworkSpecifier(spec) + .build() + val manager = context.getSystemService(ConnectivityManager::class.java) + val callback = object : ConnectivityManager.NetworkCallback() { + override fun onAvailable(net: Network) { + log.i("Wi-Fi: joined \"$ssid\"") + Thread({ connectTcp(output, name, net, ip, port) }, "gearslip-wireless-tcp").start() + } + + override fun onUnavailable() { + log.w("Wi-Fi: couldn't join \"$ssid\" (declined, out of range, or wrong password)") + runCatching { send(output, MSG_CONNECTION_STATUS, Protobuf.varintField(1, -11)) } + SessionStatus.failed( + "Couldn't join the car's Wi-Fi", + "Android didn't connect to $name's network. If a \"Connect to device\" prompt " + + "appeared, it has to be accepted.", + ) + } + + override fun onLost(net: Network) { + log.w("Wi-Fi: lost \"$ssid\"") + } + } + network?.let { runCatching { manager.unregisterNetworkCallback(it) } } + network = callback + manager.requestNetwork(request, callback, WIFI_WAIT_MS) + } + + private fun connectTcp(output: OutputStream, name: String, net: Network, ip: String, port: Int) { + SessionStatus.connecting("Wi-Fi: reaching $name") + var last: Exception? = null + repeat(TCP_TRIES) { attempt -> + try { + val socket = net.socketFactory.createSocket() + socket.tcpNoDelay = true + socket.connect(InetSocketAddress(ip, port), 5_000) + tcp = socket + log.i("TCP: connected to $ip:$port (try ${attempt + 1})") + runCatching { + send(output, MSG_START_RESPONSE, Protobuf.varintField(3, 0)) + send(output, MSG_CONNECTION_STATUS, Protobuf.varintField(1, 0)) + } + val start = onConnected + if (start == null) { + log.w("TCP: connected, but nothing is waiting for the socket") + socket.close() + } else { + start(socket) + } + return + } catch (e: Exception) { + last = e + log.w("TCP: try ${attempt + 1} to $ip:$port failed: ${e.message}") + Thread.sleep(1_000) + } + } + log.e("TCP: gave up on $ip:$port", last) + SessionStatus.failed( + "Car's Wi-Fi joined, but no connection", + "Gearslip joined $name's network but couldn't reach Android Auto at $ip:$port.", + ) + } +}