Wireless start keeps knocking and starts over after a failed step

While Gearslip waits for the car to call, it also calls the car's own service every
8 seconds, for cars that publish it late. If the Bluetooth channel closes, the Wi-Fi
join fails or TCP can't reach the car before its screen comes up, the start-up
begins again, up to three times.
This commit is contained in:
Seb3thehacker committed 2026-11-03 12:27:07 -08:00
1 parent ef1b811920
commit 1faf418798
1 file changed
+94 -11
@@ -73,6 +73,9 @@ object WirelessLink {
private const val LISTEN_MS = 120_000 // time to reconnect the phone from the car's Bluetooth menu private const val LISTEN_MS = 120_000 // time to reconnect the phone from the car's Bluetooth menu
private const val TCP_TRIES = 12 // about a minute: a car's projection listener can start well after its Wi-Fi private const val TCP_TRIES = 12 // about a minute: a car's projection listener can start well after its Wi-Fi
private const val WIFI_WAIT_MS = 60_000 private const val WIFI_WAIT_MS = 60_000
private const val KNOCK_MS = 8_000L // some cars publish their service only a while after Bluetooth connects
private const val START_RETRIES = 3
private const val RETRY_PAUSE_MS = 2_000L
/** Set by the activity: starts a session on the car's socket. */ /** Set by the activity: starts a session on the car's socket. */
@Volatile var onConnected: ((Socket) -> Unit)? = null @Volatile var onConnected: ((Socket) -> Unit)? = null
@@ -91,6 +94,10 @@ object WirelessLink {
@Volatile private var activeSsid: String? = null @Volatile private var activeSsid: String? = null
/** Bumped on each Wi-Fi join and on stop, so an older run of TCP tries ends instead of racing the new one. */ /** Bumped on each Wi-Fi join and on stop, so an older run of TCP tries ends instead of racing the new one. */
@Volatile private var tcpRun = 0 @Volatile private var tcpRun = 0
/** The socket reached the car's projection: whatever ends the start-up now, it isn't retried. */
@Volatile private var handedOver = false
/** A failure starting over can't fix, such as a VPN in the way. */
@Volatile private var noRetry = false
private val writeLock = Any() private val writeLock = Any()
/** Paired Bluetooth devices, cars or not: the driver picks theirs. Needs BLUETOOTH_CONNECT. */ /** Paired Bluetooth devices, cars or not: the driver picks theirs. Needs BLUETOOTH_CONNECT. */
@@ -109,7 +116,7 @@ object WirelessLink {
worker = Thread({ worker = Thread({
// A test feature: anything unexpected ends the attempt, not the app. // A test feature: anything unexpected ends the attempt, not the app.
try { try {
run(app, device) startUp(app, device)
} catch (e: Exception) { } catch (e: Exception) {
log.e("wireless start-up failed", e) log.e("wireless start-up failed", e)
SessionStatus.failed("Wireless start-up failed", "${e.javaClass.simpleName}: ${e.message}") SessionStatus.failed("Wireless start-up failed", "${e.javaClass.simpleName}: ${e.message}")
@@ -117,12 +124,56 @@ object WirelessLink {
}, "gearslip-wireless").apply { isDaemon = true; start() } }, "gearslip-wireless").apply { isDaemon = true; start() }
} }
/**
* A step that fails after the car's Bluetooth channel opened (the channel closing, the Wi-Fi
* join, TCP) often works the second time, so the start-up begins again, up to
* [START_RETRIES] times. Waiting for a car that never called isn't repeated: that wait is
* two minutes already.
*/
private fun startUp(context: Context, device: BluetoothDevice) {
val me = Thread.currentThread()
var tries = 0
while (true) {
handedOver = false
noRetry = false
val reached = run(context, device)
if (worker !== me || handedOver || noRetry || !reached) return
if (++tries > START_RETRIES) {
log.w("start-up failed ${tries} times; giving up")
return
}
log.i("start-up failed before the car's screen came up; starting over ($tries of $START_RETRIES)")
release(context)
SessionStatus.connecting("Starting over with ${name(device)} ($tries of $START_RETRIES)")
try {
Thread.sleep(RETRY_PAUSE_MS)
} catch (_: InterruptedException) {
return
}
if (worker !== me) return
}
}
/** Ends this try: the Bluetooth read loop stops, and [startUp] decides whether to go again. */
private fun endTry() {
runCatching { rfcomm?.close() }
}
/** Closes the Bluetooth channel, the socket and the Wi-Fi request. Safe to call any time. */ /** Closes the Bluetooth channel, the socket and the Wi-Fi request. Safe to call any time. */
fun stop(context: Context) { fun stop(context: Context) {
tcpRun++
val starting = worker != null && SessionStatus.state.value.phase == SessionStatus.Phase.CONNECTING val starting = worker != null && SessionStatus.state.value.phase == SessionStatus.Phase.CONNECTING
worker?.interrupt() worker?.interrupt()
worker = null worker = null
release(context)
// Stopped before the car's session began, so nothing else resets Home's card.
if (starting) {
log.i("wireless start-up stopped")
SessionStatus.disconnected()
}
}
private fun release(context: Context) {
tcpRun++
runCatching { listener?.close() } runCatching { listener?.close() }
listener = null listener = null
runCatching { rfcomm?.close() } runCatching { rfcomm?.close() }
@@ -134,11 +185,6 @@ object WirelessLink {
} }
network = null network = null
activeSsid = null activeSsid = null
// Stopped before the car's session began, so nothing else resets Home's card.
if (starting) {
log.i("wireless start-up stopped")
SessionStatus.disconnected()
}
} }
/** A stable id for this phone as this app sees it, standing in for the serial a real phone sends. */ /** A stable id for this phone as this app sees it, standing in for the serial a real phone sends. */
@@ -146,8 +192,9 @@ object WirelessLink {
private fun deviceId(context: Context): String = private fun deviceId(context: Context): String =
Settings.Secure.getString(context.contentResolver, Settings.Secure.ANDROID_ID) ?: "gearslip" Settings.Secure.getString(context.contentResolver, Settings.Secure.ANDROID_ID) ?: "gearslip"
/** One try. True if the car's Bluetooth channel opened, so that a failure after it is worth retrying. */
@SuppressLint("MissingPermission") @SuppressLint("MissingPermission")
private fun run(context: Context, device: BluetoothDevice) { private fun run(context: Context, device: BluetoothDevice): Boolean {
val name = name(device) val name = name(device)
log.i("--- wireless start-up with $name (${device.address}) ---") log.i("--- wireless start-up with $name (${device.address}) ---")
SessionStatus.connecting("Bluetooth: reaching $name") SessionStatus.connecting("Bluetooth: reaching $name")
@@ -163,12 +210,12 @@ object WirelessLink {
"Gearslip needs the Nearby devices permission to reach $name. Allow it in the app's " + "Gearslip needs the Nearby devices permission to reach $name. Allow it in the app's " +
"settings, then try again.", "settings, then try again.",
) )
return return false
} catch (e: IOException) { } catch (e: IOException) {
// Most factory head units publish no service of their own: they look for the phone's // Most factory head units publish no service of their own: they look for the phone's
// and connect to it, so wait for the car instead. // and connect to it, so wait for the car instead.
log.i("Bluetooth: $name has no Android Auto service to call (${e.message}); waiting for it to call us") log.i("Bluetooth: $name has no Android Auto service to call (${e.message}); waiting for it to call us")
waitForCar(context, name) ?: return waitForCar(context, device, name) ?: return false
} }
rfcomm = socket rfcomm = socket
log.i("Bluetooth: channel open") log.i("Bluetooth: channel open")
@@ -261,6 +308,7 @@ object WirelessLink {
} catch (e: IOException) { } catch (e: IOException) {
if (rfcomm != null) log.w("Bluetooth: channel closed (${e.message})") if (rfcomm != null) log.w("Bluetooth: channel closed (${e.message})")
} }
return true
} }
/** /**
@@ -269,7 +317,7 @@ object WirelessLink {
* Bluetooth, so the driver may have to reconnect the phone from the car's menu. * Bluetooth, so the driver may have to reconnect the phone from the car's menu.
*/ */
@SuppressLint("MissingPermission") @SuppressLint("MissingPermission")
private fun waitForCar(context: Context, name: String): BluetoothSocket? { private fun waitForCar(context: Context, device: BluetoothDevice, name: String): BluetoothSocket? {
val adapter = context.getSystemService(BluetoothManager::class.java)?.adapter ?: return null val adapter = context.getSystemService(BluetoothManager::class.java)?.adapter ?: return null
val server = try { val server = try {
adapter.listenUsingRfcommWithServiceRecord("Android Auto", AA_UUID) adapter.listenUsingRfcommWithServiceRecord("Android Auto", AA_UUID)
@@ -281,12 +329,40 @@ object WirelessLink {
listener = server listener = server
SessionStatus.connecting("Waiting for $name. If nothing happens, disconnect the phone in the car's Bluetooth menu and connect it again") SessionStatus.connecting("Waiting for $name. If nothing happens, disconnect the phone in the car's Bluetooth menu and connect it again")
log.i("Bluetooth: Android Auto service published, waiting ${LISTEN_MS / 1000}s for $name") log.i("Bluetooth: Android Auto service published, waiting ${LISTEN_MS / 1000}s for $name")
// Meanwhile, keep calling the car's own service: a car that publishes it late answers one
// of these instead of calling us. Whichever connects first is the channel.
val done = java.util.concurrent.atomic.AtomicBoolean(false)
val knocked = java.util.concurrent.atomic.AtomicReference<BluetoothSocket?>(null)
val knocker = Thread({
while (!done.get()) {
try {
Thread.sleep(KNOCK_MS)
} catch (_: InterruptedException) {
return@Thread
}
if (done.get()) return@Thread
val socket = runCatching { device.createRfcommSocketToServiceRecord(AA_UUID).also { it.connect() } }
.getOrNull() ?: continue
if (done.compareAndSet(false, true)) {
log.i("Bluetooth: $name's own Android Auto service answered")
knocked.set(socket)
runCatching { server.close() }
} else {
runCatching { socket.close() }
}
return@Thread
}
}, "gearslip-wireless-knock").apply { isDaemon = true; start() }
return try { return try {
server.accept(LISTEN_MS).also { server.accept(LISTEN_MS).also {
if (!done.compareAndSet(false, true)) runCatching { knocked.get()?.close() }
val from = runCatching { it.remoteDevice.name }.getOrNull() ?: "a device" val from = runCatching { it.remoteDevice.name }.getOrNull() ?: "a device"
log.i("Bluetooth: $from connected to the Android Auto service") log.i("Bluetooth: $from connected to the Android Auto service")
} }
} catch (e: IOException) { } catch (e: IOException) {
knocker.join(500)
knocked.get()?.let { return it }
done.set(true)
if (listener == null) return null // stopped if (listener == null) return null // stopped
log.w("Bluetooth: no car connected within ${LISTEN_MS / 1000}s (${e.message})") log.w("Bluetooth: no car connected within ${LISTEN_MS / 1000}s (${e.message})")
SessionStatus.failed( SessionStatus.failed(
@@ -296,6 +372,8 @@ object WirelessLink {
) )
null null
} finally { } finally {
done.set(true)
knocker.interrupt()
runCatching { server.close() } runCatching { server.close() }
listener = null listener = null
} }
@@ -355,6 +433,7 @@ object WirelessLink {
"Android didn't connect to $name's network. If a \"Connect to device\" prompt " + "Android didn't connect to $name's network. If a \"Connect to device\" prompt " +
"appeared, it has to be accepted.", "appeared, it has to be accepted.",
) )
endTry()
} }
// Android can hold an app's traffic back on a network it has joined (battery or data // Android can hold an app's traffic back on a network it has joined (battery or data
@@ -471,6 +550,7 @@ object WirelessLink {
socket.tcpNoDelay = true socket.tcpNoDelay = true
socket.connect(InetSocketAddress(ip, port), 5_000) socket.connect(InetSocketAddress(ip, port), 5_000)
tcp = socket tcp = socket
handedOver = true
log.i("TCP: connected to $ip:$port (try ${attempt + 1})") log.i("TCP: connected to $ip:$port (try ${attempt + 1})")
val start = onConnected val start = onConnected
if (start == null) { if (start == null) {
@@ -498,11 +578,14 @@ object WirelessLink {
"Gearslip joined $name's network, but a VPN on this phone won't let it reach the car. " + "Gearslip joined $name's network, but a VPN on this phone won't let it reach the car. " +
"Turn the VPN off, or exclude Gearslip in its settings, then try again.", "Turn the VPN off, or exclude Gearslip in its settings, then try again.",
) )
noRetry = true
endTry()
return return
} }
SessionStatus.failed( SessionStatus.failed(
"Car's Wi-Fi joined, but no connection", "Car's Wi-Fi joined, but no connection",
"Gearslip joined $name's network but couldn't reach Android Auto at $ip:$port.", "Gearslip joined $name's network but couldn't reach Android Auto at $ip:$port.",
) )
endTry()
} }
} }