wireless func added (testing)

This commit is contained in:
Seb3thehacker committed 2026-10-07 18:45:57 -07:00
1 parent 9dc705c8cf
commit 39e1a5ee09
5 files changed
+421 -3

No files matched your search

+14
View File
@@ -19,6 +19,20 @@
<uses-permission android:name="android.permission.ACCESS_COARSE_LOCATION" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_LOCATION" />
<!--
Wireless Android Auto: Bluetooth reaches the paired car's Android Auto service, which
hands over its Wi-Fi details; Gearslip then joins that network to reach the car.
-->
<uses-permission android:name="android.permission.BLUETOOTH_CONNECT" />
<uses-permission android:name="android.permission.BLUETOOTH" android:maxSdkVersion="30" />
<uses-permission
android:name="android.permission.NEARBY_WIFI_DEVICES"
android:usesPermissionFlags="neverForLocation" />
<uses-permission android:name="android.permission.ACCESS_WIFI_STATE" />
<uses-permission android:name="android.permission.CHANGE_WIFI_STATE" />
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
<uses-permission android:name="android.permission.CHANGE_NETWORK_STATE" />
<!-- The dashboard opened from the car clock: today's agenda and current weather. -->
<uses-permission android:name="android.permission.READ_CALENDAR" />
@@ -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
}
@@ -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.
@@ -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<List<BluetoothDevice>?>(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))
}
@@ -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<BluetoothDevice> =
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<String, Int>? = 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.",
)
}
}