Vectrum
Sign In

Android: skip refused routes, notice dead ones, and move back to faster ones

Finishes the phone's routes to match the desktop's. A route that answers
with another device's key is skipped instead of ending the connection; it's
denied only when every route that answered refused. OkHttp pings the host,
so a route that went away is noticed. While on a slower route the phone
tries the faster ones every minute and whenever its network changes, and
moves as soon as one works. LAN addresses get a short connect timeout, and
Connections shows the route in use and the ones it can fall back on.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
JjohannSocommitted 1 parent 6662085commit2690ecc

Showing 5 changed files with 324 additions and 101 deletions.

+56−26android/app/src/main/java/cc/vectrum/code/data/AppState.ktView File
@@ -1,6 +1,10 @@
11 package cc.vectrum.code.data
22
33 import android.content.Context
4+ import android.net.ConnectivityManager
5+ import android.net.Network
6+ import android.os.Handler
7+ import android.os.Looper
48 import android.content.SharedPreferences
59 import android.os.Build
610 import androidx.compose.runtime.getValue
@@ -17,6 +21,8 @@ import cc.vectrum.code.net.RemoteFileDiff
1721 import cc.vectrum.code.net.RemoteInstance
1822 import cc.vectrum.code.net.RemoteModel
1923 import cc.vectrum.code.net.Route
24+ import cc.vectrum.code.net.guessKind
25+ import cc.vectrum.code.net.routeLabel
2026 import cc.vectrum.code.net.routeRank
2127 import cc.vectrum.code.net.SavedItem
2228 import cc.vectrum.code.net.ServerMessage
@@ -56,20 +62,6 @@ data class SavedHost(
5662 }
5763 }
5864
59- /** A route's kind from its address alone, as the desktop guesses for typed addresses. */
60- fun guessKind(address: String): String {
61- val host = address.substringBeforeLast(':')
62- val octets = host.split('.').mapNotNull { it.toIntOrNull() }
63- if (octets.size != 4) return "other"
64- val (a, b) = octets
65- return when {
66- a == 127 -> "loopback"
67- a == 10 || (a == 172 && b in 16..31) || (a == 192 && b == 168) -> "lan"
68- a == 100 && b in 64..127 -> "vpn"
69- else -> "other"
70- }
71- }
72-
7365 /** A thread anywhere: the host it lives on and its id there. */
7466 data class ThreadRef(val host: String, val thread: String)
7567
@@ -100,9 +92,29 @@ class AppState(context: Context) {
10092 saved.forEach { hosts.add(HostState(it, this)) }
10193 }
10294
103- fun start() = hosts.filter { it.saved.enabled }.forEach { it.connect(deviceName) }
95+ private val connectivity = context.getSystemService(ConnectivityManager::class.java)
96+ private val main = Handler(Looper.getMainLooper())
97+
98+ /** Tells the connections when the phone's network changes: Wi-Fi, mobile data, a VPN such as Tailscale. */
99+ private val networkCallback = object : ConnectivityManager.NetworkCallback() {
100+ override fun onAvailable(network: Network) {
101+ main.post { hosts.forEach { it.networkChanged() } }
102+ }
103+
104+ override fun onLost(network: Network) {
105+ main.post { hosts.forEach { it.networkChanged() } }
106+ }
107+ }
108+
109+ fun start() {
110+ hosts.filter { it.saved.enabled }.forEach { it.connect(deviceName) }
111+ runCatching { connectivity?.registerDefaultNetworkCallback(networkCallback) }
112+ }
104113
105- fun stop() = hosts.forEach { it.disconnect() }
114+ fun stop() {
115+ runCatching { connectivity?.unregisterNetworkCallback(networkCallback) }
116+ hosts.forEach { it.disconnect() }
117+ }
106118
107119 /** Reconnects whatever dropped, as when the app comes back to the foreground. */
108120 fun resume() = hosts.filter { it.saved.enabled && it.status !is ConnectionStatus.Connected }.forEach { it.retry() }
@@ -168,6 +180,10 @@ class HostState(saved: SavedHost, private val app: AppState) {
168180 /** The address the connection dials or uses now. */
169181 var activeAddress by mutableStateOf(saved.addresses.firstOrNull() ?: saved.address)
170182 private set
183+
184+ /** The network [activeAddress] goes over, as the host reported it or guessed: `LAN`, `Tailscale`, `VPN`. */
185+ val activeRoute: String
186+ get() = routeLabel(saved.routes.find { it.address == activeAddress }?.kind ?: guessKind(activeAddress))
171187 var version by mutableStateOf("")
172188 private set
173189
@@ -210,7 +226,7 @@ class HostState(saved: SavedHost, private val app: AppState) {
210226
211227 fun connect(deviceName: String) {
212228 connection?.stop()
213- val connection = Connection({ saved.addresses }, saved.key, deviceName, ::onStatus, ::onMessage) { activeAddress = it }
229+ val connection = Connection({ saved.addresses }, saved.key, deviceName, ::onStatus, ::onMessage, ::onAddress)
214230 this.connection = connection
215231 connection.start()
216232 }
@@ -232,6 +248,16 @@ class HostState(saved: SavedHost, private val app: AppState) {
232248
233249 fun send(message: ClientMessage): Boolean = connection?.send(message) ?: false
234250
251+ internal fun networkChanged() {
252+ connection?.networkChanged()
253+ }
254+
255+ private fun onAddress(address: String) {
256+ // Moving to a faster route while connected: the old connection's shells and replies go with it.
257+ if (connected && address != activeAddress) failPending("The connection moved to a faster route.")
258+ activeAddress = address
259+ }
260+
235261 /** Keeps the routes a host reports, replacing the ones it reported before. Older hosts report none. */
236262 private fun learnRoutes(routes: List<Route>) {
237263 if (routes.isEmpty()) return
@@ -248,18 +274,22 @@ class HostState(saved: SavedHost, private val app: AppState) {
248274 subscribed.forEach { send(ClientMessage.Subscribe(it)) }
249275 }
250276 if (status !is ConnectionStatus.Connected && status !is ConnectionStatus.Connecting) {
251- // Whatever waits on a reply won't get one from this connection.
252- folderRequests.values.forEach { it.complete(ServerMessage.Folders(error = "The host is offline.")) }
253- folderRequests.clear()
254- diffRequests.values.forEach { it.complete(ServerMessage.DiffReply(error = "The host is offline.")) }
255- diffRequests.clear()
256- deviceRequests.values.forEach { it.complete(ServerMessage.DeviceReply(error = "The host is offline.")) }
257- deviceRequests.clear()
258- terminals.values.forEach { it.onExit("The connection dropped.") }
259- terminals.clear()
277+ failPending("The connection dropped.")
260278 }
261279 }
262280
281+ /** Whatever waits on a reply won't get one from this connection. */
282+ private fun failPending(reason: String) {
283+ folderRequests.values.forEach { it.complete(ServerMessage.Folders(error = "The host is offline.")) }
284+ folderRequests.clear()
285+ diffRequests.values.forEach { it.complete(ServerMessage.DiffReply(error = "The host is offline.")) }
286+ diffRequests.clear()
287+ deviceRequests.values.forEach { it.complete(ServerMessage.DeviceReply(error = "The host is offline.")) }
288+ deviceRequests.clear()
289+ terminals.values.forEach { it.onExit(reason) }
290+ terminals.clear()
291+ }
292+
263293 private fun onMessage(message: ServerMessage) {
264294 when (message) {
265295 is ServerMessage.State -> {
+208−74android/app/src/main/java/cc/vectrum/code/net/Connection.ktView File
@@ -26,8 +26,11 @@ sealed interface ConnectionStatus {
2626 * One WebSocket to a host, kept up: it says hello with the key, hands every
2727 * message to [onMessage] on the main thread, and reconnects with a backoff
2828 * when the connection drops. A host can be reached several ways (the LAN,
29- * Tailscale, another VPN): each attempt goes down [addresses], fastest first,
30- * until one answers, and [onAddress] says which one is in use.
29+ * Tailscale, another VPN), like the desktop's routes: each attempt goes down
30+ * [addresses], fastest first, until one answers with the right key, and
31+ * [onAddress] says which one is in use. While on a slower one it keeps trying
32+ * the faster ones, every minute and whenever the phone's network changes, and
33+ * moves as soon as one works. Pings notice a route that went away.
3134 */
3235 class Connection(
3336 private val addresses: () -> List<String>,
@@ -38,14 +41,23 @@ class Connection(
3841 private val onAddress: (String) -> Unit = {},
3942 ) {
4043 private val main = Handler(Looper.getMainLooper())
41- private var socket: WebSocket? = null
42- private var welcomed = false
4344 private var stopped = false
4445 private var attempt = 0
4546 private var generation = 0
4647
47- /** Which of [addresses] this attempt dials. */
48- private var route = 0
48+ /** The connection in use, or the one being dialed. */
49+ private var current: Dial? = null
50+
51+ /** A faster route being tried while [current] works. */
52+ private var probe: Dial? = null
53+
54+ /** This round's addresses, which one it dials, and what the others said. */
55+ private var round = emptyList<String>()
56+ private var index = 0
57+ private var denied: String? = null
58+ private val failures = mutableListOf<String>()
59+
60+ private val welcomed: Boolean get() = current?.welcomed == true
4961
5062 fun start() {
5163 stopped = false
@@ -56,8 +68,10 @@ class Connection(
5668 stopped = true
5769 generation++
5870 main.removeCallbacksAndMessages(null)
59- socket?.close(1000, "bye")
60- socket = null
71+ probe?.socket?.cancel()
72+ probe = null
73+ current?.socket?.close(1000, "bye")
74+ current = null
6175 }
6276
6377 /** Drops the connection and dials again at once, as after the network comes back. */
@@ -65,98 +79,218 @@ class Connection(
6579 if (stopped) return
6680 generation++
6781 main.removeCallbacksAndMessages(null)
68- socket?.cancel()
69- socket = null
82+ probe?.socket?.cancel()
83+ probe = null
84+ current?.socket?.cancel()
85+ current = null
7086 attempt = 0
71- route = 0
7287 open()
7388 }
7489
90+ /**
91+ * The phone's network changed: a host that was out of reach may answer
92+ * now, and a faster route may have come up (home Wi-Fi again).
93+ */
94+ fun networkChanged() {
95+ if (stopped) return
96+ when {
97+ welcomed -> probeFaster()
98+ // Still going down this round's addresses.
99+ current != null -> {}
100+ else -> reconnect()
101+ }
102+ }
103+
75104 /** Sends once welcomed; messages before that are dropped like the desktop drops them. */
76105 fun send(message: ClientMessage): Boolean {
77- val socket = socket ?: return false
78- if (!welcomed) return false
79- return socket.send(encode(message))
106+ val dial = current ?: return false
107+ if (!dial.welcomed) return false
108+ return dial.socket?.send(encode(message)) ?: false
80109 }
81110
82- val connected: Boolean get() = welcomed && socket != null
111+ val connected: Boolean get() = welcomed
83112
84113 private fun open() {
85- val gen = ++generation
86- welcomed = false
114+ generation++
87115 onStatus(ConnectionStatus.Connecting)
88- val list = addresses()
89- if (list.isEmpty()) {
116+ round = addresses().map(::withPort).distinct()
117+ index = 0
118+ denied = null
119+ failures.clear()
120+ if (round.isEmpty()) {
90121 onStatus(ConnectionStatus.Offline("No address to reach this computer at."))
91122 return
92123 }
93- val address = withPort(list[route.coerceAtMost(list.lastIndex)])
124+ dialNext()
125+ }
126+
127+ /** Dials this round's next address, or gives up on the round. */
128+ private fun dialNext() {
129+ val address = round.getOrNull(index)
130+ if (address == null) {
131+ exhausted()
132+ return
133+ }
94134 onAddress(address)
95- val request = Request.Builder().url("ws://$address/").build()
96- socket = client.newWebSocket(
97- request,
98- object : WebSocketListener() {
99- override fun onOpen(webSocket: WebSocket, response: Response) {
100- webSocket.send(encode(ClientMessage.Hello(key = key, name = deviceName)))
101- }
135+ current = dial(address, probe = false)
136+ }
102137
103- override fun onMessage(webSocket: WebSocket, text: String) {
104- val message = parseServerMessage(text) ?: return
105- main.post {
106- if (gen != generation) return@post
107- when (message) {
108- is ServerMessage.Welcome -> {
109- welcomed = true
110- attempt = 0
111- onStatus(ConnectionStatus.Connected(message.host, message.version))
112- // Its routes, for the next time.
113- onMessage(message)
114- }
115- is ServerMessage.Denied -> {
116- stopped = true
117- onStatus(ConnectionStatus.Denied(message.reason))
118- }
119- else -> onMessage(message)
120- }
121- }
122- }
138+ private fun dial(address: String, probe: Boolean): Dial {
139+ val dial = Dial(generation, address, probe)
140+ // A LAN address answers quickly when it's there; away from home it isn't.
141+ val http = if (guessKind(address) in setOf("lan", "loopback")) lanClient else client
142+ dial.socket = http.newWebSocket(Request.Builder().url("ws://$address/").build(), dial)
143+ return dial
144+ }
123145
124- override fun onClosed(webSocket: WebSocket, code: Int, reason: String) =
125- dropped(gen, reason.ifBlank { "The host closed the connection." })
126-
127- override fun onFailure(webSocket: WebSocket, t: Throwable, response: Response?) =
128- dropped(gen, t.message ?: t.javaClass.simpleName)
129- },
130- )
131- }
132-
133- private fun dropped(gen: Int, reason: String) {
134- main.post {
135- if (gen != generation || stopped) return@post
136- val wasWelcomed = welcomed
137- welcomed = false
138- socket = null
139- // The next route, unless this one was working: then start over from the fastest.
140- if (!wasWelcomed && route + 1 < addresses().size) {
141- route++
142- open()
143- return@post
146+ /** No address worked. Denied only when every one that answered refused the key: an address on another network can be another device's. */
147+ private fun exhausted() {
148+ current = null
149+ val denied = denied
150+ if (denied != null && failures.isEmpty()) {
151+ stopped = true
152+ onStatus(ConnectionStatus.Denied(denied))
153+ return
154+ }
155+ onStatus(ConnectionStatus.Offline(failures.joinToString("; ").ifEmpty { "Couldn't connect." }))
156+ val gen = generation
157+ val delay = RETRY_DELAYS[attempt.coerceAtMost(RETRY_DELAYS.lastIndex)]
158+ attempt++
159+ main.postDelayed({ if (gen == generation && !stopped) open() }, delay)
160+ }
161+
162+ private fun welcome(dial: Dial, message: ServerMessage.Welcome) {
163+ dial.welcomed = true
164+ if (dial.probe) {
165+ // A faster route answered: move to it.
166+ probe = null
167+ current?.let {
168+ it.over = true
169+ it.socket?.close(1000, "moved")
144170 }
145- route = 0
146- onStatus(ConnectionStatus.Offline(reason))
147- val delay = RETRY_DELAYS[attempt.coerceAtMost(RETRY_DELAYS.lastIndex)]
148- attempt++
149- main.postDelayed({ if (gen == generation && !stopped) open() }, delay)
171+ onAddress(dial.address)
172+ }
173+ current = dial
174+ attempt = 0
175+ onStatus(ConnectionStatus.Connected(message.host, message.version))
176+ // Its routes, for the next time.
177+ onMessage(message)
178+ scheduleProbe()
179+ }
180+
181+ private val probeTick = Runnable { if (!stopped) probeFaster() }
182+
183+ /** Tries the faster routes again in a while; one timer, however often it's asked. */
184+ private fun scheduleProbe() {
185+ main.removeCallbacks(probeTick)
186+ main.postDelayed(probeTick, PROBE_EVERY)
187+ }
188+
189+ /** Tries the routes faster than the one in use, one by one, without dropping it. */
190+ private fun probeFaster(after: String? = null) {
191+ val active = current?.takeIf { it.welcomed } ?: return
192+ if (probe != null && after == null) return
193+ val faster = addresses().map(::withPort).distinct().takeWhile { it != active.address }
194+ val next = if (after == null) faster.firstOrNull() else faster.getOrNull(faster.indexOf(after) + 1)
195+ if (next == null) {
196+ probe = null
197+ scheduleProbe()
198+ return
199+ }
200+ probe = dial(next, probe = true)
201+ }
202+
203+ private fun lost(dial: Dial, reason: String) {
204+ if (dial.over) return
205+ dial.over = true
206+ if (dial.gen != generation || stopped) return
207+ when {
208+ dial.probe -> if (probe === dial) probeFaster(after = dial.address)
209+ dial !== current -> {}
210+ !dial.welcomed -> {
211+ failures.add(if (round.size == 1) reason else "${routeLabel(guessKind(dial.address))}: $reason")
212+ index++
213+ dialNext()
214+ }
215+ else -> {
216+ // The route in use went away; start over from the fastest.
217+ generation++
218+ probe?.socket?.cancel()
219+ probe = null
220+ current = null
221+ onStatus(ConnectionStatus.Offline(reason))
222+ val gen = generation
223+ val delay = RETRY_DELAYS[attempt.coerceAtMost(RETRY_DELAYS.lastIndex)]
224+ attempt++
225+ main.postDelayed({ if (gen == generation && !stopped) open() }, delay)
226+ }
227+ }
228+ }
229+
230+ private fun refused(dial: Dial, reason: String) {
231+ if (dial.over) return
232+ dial.over = true
233+ dial.socket?.cancel()
234+ if (dial.gen != generation || stopped) return
235+ if (dial.probe) {
236+ if (probe === dial) probeFaster(after = dial.address)
237+ } else if (dial === current) {
238+ denied = denied ?: reason
239+ index++
240+ dialNext()
241+ }
242+ }
243+
244+ /** One WebSocket: the connection in use, one being dialed, or a probe of a faster route. */
245+ private inner class Dial(val gen: Int, val address: String, val probe: Boolean) : WebSocketListener() {
246+ var socket: WebSocket? = null
247+ var welcomed = false
248+
249+ /** Done with: moved from, refused, or lost. */
250+ var over = false
251+
252+ override fun onOpen(webSocket: WebSocket, response: Response) {
253+ webSocket.send(encode(ClientMessage.Hello(key = key, name = deviceName)))
254+ }
255+
256+ override fun onMessage(webSocket: WebSocket, text: String) {
257+ val message = parseServerMessage(text) ?: return
258+ main.post {
259+ if (over || gen != generation) return@post
260+ when {
261+ !welcomed && message is ServerMessage.Welcome -> welcome(this, message)
262+ !welcomed && message is ServerMessage.Denied -> refused(this, message.reason)
263+ welcomed && this === current -> onMessage(message)
264+ }
265+ }
266+ }
267+
268+ override fun onClosing(webSocket: WebSocket, code: Int, reason: String) {
269+ webSocket.close(1000, null)
270+ }
271+
272+ override fun onClosed(webSocket: WebSocket, code: Int, reason: String) {
273+ main.post { lost(this, reason.ifBlank { "The host closed the connection." }) }
274+ }
275+
276+ override fun onFailure(webSocket: WebSocket, t: Throwable, response: Response?) {
277+ main.post { lost(this, t.message ?: t.javaClass.simpleName) }
150278 }
151279 }
152280
153281 companion object {
154282 private val RETRY_DELAYS = longArrayOf(1_000, 2_000, 4_000, 8_000, 15_000)
155283
284+ /** How often to try the faster routes while on a slower one. */
285+ private const val PROBE_EVERY = 60_000L
286+
156287 private val client = OkHttpClient.Builder()
157- .connectTimeout(5, TimeUnit.SECONDS)
288+ .connectTimeout(6, TimeUnit.SECONDS)
158289 .readTimeout(0, TimeUnit.SECONDS)
159- .pingInterval(0, TimeUnit.SECONDS)
290+ // The host answers pings even when idle; a missed pong means the route went away.
291+ .pingInterval(15, TimeUnit.SECONDS)
160292 .build()
293+
294+ private val lanClient = client.newBuilder().connectTimeout(2, TimeUnit.SECONDS).build()
161295 }
162296 }
+30−0android/app/src/main/java/cc/vectrum/code/net/Protocol.ktView File
@@ -534,6 +534,36 @@ fun encode(message: ClientMessage): String = WireJson.encodeToString(ClientMessa
534534 @Serializable
535535 data class Route(val address: String = "", val kind: String = "lan", val learned: Boolean = false)
536536
537+ /** A route's kind from its address alone, as the desktop guesses for typed addresses. */
538+ fun guessKind(address: String): String {
539+ val host = withPort(address).substringBeforeLast(':').lowercase()
540+ val octets = host.split('.').map { it.toIntOrNull() }
541+ if (octets.size == 4 && octets.all { it != null && it in 0..255 }) {
542+ val (a, b) = octets.map { it!! }
543+ return when {
544+ a == 127 -> "loopback"
545+ a == 10 || (a == 172 && b in 16..31) || (a == 192 && b == 168) -> "lan"
546+ a == 100 && b in 64..127 -> "vpn"
547+ else -> "other"
548+ }
549+ }
550+ return when {
551+ host == "localhost" -> "loopback"
552+ host.endsWith(".ts.net") -> "tailnet"
553+ host.endsWith(".local") || !host.contains('.') && !host.contains(':') -> "lan"
554+ else -> "other"
555+ }
556+ }
557+
558+ /** A route's kind as the desktop names it: `LAN`, `Tailscale`, `VPN`. */
559+ fun routeLabel(kind: String): String = when (kind) {
560+ "loopback" -> "This device"
561+ "lan" -> "LAN"
562+ "tailnet" -> "Tailscale"
563+ "vpn" -> "VPN"
564+ else -> "Direct"
565+ }
566+
537567 /** How fast a route likely is, fastest first, as the desktop ranks them. */
538568 fun routeRank(kind: String): Int = when (kind) {
539569 "loopback" -> 0
+14−1android/app/src/main/java/cc/vectrum/code/ui/settings/ConnectionsPage.ktView File
@@ -12,6 +12,8 @@ import androidx.compose.ui.unit.dp
1212 import cc.vectrum.code.data.AppState
1313 import cc.vectrum.code.data.HostState
1414 import cc.vectrum.code.net.ConnectionStatus
15+ import cc.vectrum.code.net.guessKind
16+ import cc.vectrum.code.net.routeLabel
1517 import cc.vectrum.code.theme.Cap
1618 import cc.vectrum.code.theme.CapKind
1719 import cc.vectrum.code.theme.CapSize
@@ -75,7 +77,7 @@ private fun HostRow(app: AppState, host: HostState) {
7577 var removing by remember { mutableStateOf(false) }
7678 SettingRow(
7779 host.name,
78- description = "${host.activeAddress} · $text",
80+ description = hostDescription(host, text),
7981 inline = true,
8082 titleSuffix = {
8183 Row(horizontalArrangement = Arrangement.spacedBy(6.dp), verticalAlignment = Alignment.CenterVertically) {
@@ -103,3 +105,14 @@ private fun HostRow(app: AppState, host: HostState) {
103105 )
104106 }
105107 }
108+
109+ /** Where a host is reached: the route in use and the others it can fall back on, as the desktop shows it. */
110+ private fun hostDescription(host: HostState, status: String): String {
111+ val others = host.saved.addresses.filter { it != host.activeAddress }
112+ val also = if (others.isEmpty()) "" else {
113+ " · also " + others.joinToString(", ") { address ->
114+ "${routeLabel(host.saved.routes.find { it.address == address }?.kind ?: guessKind(address))} $address"
115+ }
116+ }
117+ return "${host.activeRoute} · ${host.activeAddress} · $status$also"
118+ }
+16−0android/app/src/test/java/cc/vectrum/code/net/PairingTest.ktView File
@@ -26,4 +26,20 @@ class PairingTest {
2626 )
2727 assertEquals(listOf("192.168.2.10:47823", "100.90.0.1:47823", "100.101.1.2:47823"), host.addresses)
2828 }
29+
30+ /** As the desktop guesses for addresses typed or pasted; only the host knows Tailscale from other VPNs by number. */
31+ @Test
32+ fun guessesKinds() {
33+ assertEquals("lan", guessKind("192.168.2.10:47823"))
34+ assertEquals("lan", guessKind("10.0.0.5"))
35+ assertEquals("vpn", guessKind("100.101.1.2:47823"))
36+ assertEquals("loopback", guessKind("127.0.0.1"))
37+ assertEquals("other", guessKind("8.8.8.8"))
38+ assertEquals("tailnet", guessKind("box.tail1234.ts.net"))
39+ assertEquals("lan", guessKind("box.local"))
40+ assertEquals("lan", guessKind("box"))
41+ assertEquals("other", guessKind("example.com:9000"))
42+ assertEquals("Tailscale", routeLabel("tailnet"))
43+ assertEquals("LAN", routeLabel(guessKind("192.168.2.10")))
44+ }
2945 }