Vectrum
Sign In

Android: pair with every address a pairing code lists, and fall back between routes

Pairing codes now carry each address the computer listens on. The phone tries
them fastest first, saves the routes the host reports in Welcome and Routes,
and shows the address in use.

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

Showing 10 changed files with 162 additions and 35 deletions.

+4−1android/README.mdView File
@@ -8,7 +8,10 @@ the computer, with its agents, files, terminals and devices.
88
99 On the computer, open Settings → Connections and turn on **Allow remote connections**. Then, on
1010 the phone, scan the QR code shown under **Pairing code**, or paste the code
11- (`192.168.2.10:47823/<key>`). A `vectrum://…` link pairs too. Several computers can be paired;
11+ (`192.168.2.10:47823,100.101.1.2:47823/<key>`: every address the computer listens on, and its
12+ key). A `vectrum://…` link pairs too. The phone tries the addresses fastest first (the LAN, then
13+ Tailscale, then another VPN), keeps the ones the computer reports as it moves between
14+ networks, and falls back on the next one when an address doesn't answer. Several computers can be paired;
1215 their threads share the sidebar, tagged with each one's name. The connection isn't encrypted,
1316 so pair on networks you trust or over a VPN such as Tailscale or WireGuard.
1417
+4−5android/app/src/main/java/cc/vectrum/code/MainActivity.ktView File
@@ -12,7 +12,6 @@ import androidx.compose.runtime.DisposableEffect
1212 import androidx.compose.runtime.LaunchedEffect
1313 import androidx.compose.runtime.remember
1414 import cc.vectrum.code.net.parsePairing
15- import cc.vectrum.code.net.withPort
1615 import cc.vectrum.code.theme.ProvidePalette
1716 import cc.vectrum.code.theme.Themes
1817 import cc.vectrum.code.ui.Root
@@ -56,13 +55,13 @@ class MainActivity : ComponentActivity() {
5655 app.resume()
5756 }
5857
59- /** A `vectrum://address/key` link pairs with that host. */
58+ /** A `vectrum://addresses/key` link pairs with that host. */
6059 private fun handle(intent: Intent?) {
6160 val data = intent?.data ?: return
6261 if (data.scheme != "vectrum") return
63- val (address, key) = parsePairing(data.toString())
64- if (address.isBlank() || key == null) return
65- app.addHost(withPort(address), key)
62+ val (addresses, key) = parsePairing(data.toString())
63+ if (addresses.isEmpty() || key == null) return
64+ app.addHost(addresses, key)
6665 nav.home()
6766 }
6867 }
+51−7android/app/src/main/java/cc/vectrum/code/data/AppState.ktView File
@@ -16,6 +16,8 @@ import cc.vectrum.code.net.ProjectInfo
1616 import cc.vectrum.code.net.RemoteFileDiff
1717 import cc.vectrum.code.net.RemoteInstance
1818 import cc.vectrum.code.net.RemoteModel
19+ import cc.vectrum.code.net.Route
20+ import cc.vectrum.code.net.routeRank
1921 import cc.vectrum.code.net.SavedItem
2022 import cc.vectrum.code.net.ServerMessage
2123 import cc.vectrum.code.net.SettingsPatch
@@ -23,6 +25,7 @@ import cc.vectrum.code.net.ThreadAction
2325 import cc.vectrum.code.net.ThreadDetail
2426 import cc.vectrum.code.net.ThreadSummary
2527 import cc.vectrum.code.net.WireJson
28+ import cc.vectrum.code.net.withPort
2629 import kotlinx.coroutines.CompletableDeferred
2730 import kotlinx.serialization.Serializable
2831 import kotlinx.serialization.builtins.ListSerializer
@@ -37,10 +40,35 @@ data class SavedHost(
3740 val id: String = UUID.randomUUID().toString(),
3841 /** The host's name, as it last said; shown until it says again. */
3942 val name: String = "",
43+ /** The address paired with, or the pairing code's addresses, comma-separated. */
4044 val address: String = "",
4145 val key: String = "",
4246 val enabled: Boolean = true,
43- )
47+ /** The routes the host reported, saved for the next time. */
48+ val routes: List<Route> = emptyList(),
49+ ) {
50+ /** Every way to reach the host, fastest kind first, the paired ones before learned ones of a kind. */
51+ val addresses: List<String>
52+ get() {
53+ val paired = address.split(',').map { it.trim() }.filter { it.isNotEmpty() }.map(::withPort)
54+ val all = paired.map { Route(it, guessKind(it)) } + routes.map { it.copy(address = withPort(it.address)) }
55+ return all.sortedBy { routeRank(it.kind) }.map { it.address }.distinct()
56+ }
57+ }
58+
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+ }
4472
4573 /** A thread anywhere: the host it lives on and its id there. */
4674 data class ThreadRef(val host: String, val thread: String)
@@ -81,10 +109,13 @@ class AppState(context: Context) {
81109
82110 fun host(id: String): HostState? = hosts.find { it.saved.id == id }
83111
84- fun addHost(address: String, key: String, name: String = ""): HostState {
85- val existing = hosts.find { it.saved.address == address }
112+ /** Pairs with a host at `addresses` (a pairing code's, or one typed); the same host again updates it. */
113+ fun addHost(addresses: List<String>, key: String, name: String = ""): HostState {
114+ val wanted = addresses.map(::withPort)
115+ val address = wanted.joinToString(",")
116+ val existing = hosts.find { host -> host.saved.addresses.any { it in wanted } }
86117 if (existing != null) {
87- existing.update(existing.saved.copy(key = key, enabled = true))
118+ existing.update(existing.saved.copy(address = address, key = key, enabled = true))
88119 existing.retry()
89120 return existing
90121 }
@@ -132,7 +163,11 @@ class HostState(saved: SavedHost, private val app: AppState) {
132163 private set
133164
134165 /** The name the host gives itself, or the address until it does. */
135- val name: String get() = saved.name.ifBlank { saved.address }
166+ val name: String get() = saved.name.ifBlank { activeAddress }
167+
168+ /** The address the connection dials or uses now. */
169+ var activeAddress by mutableStateOf(saved.addresses.firstOrNull() ?: saved.address)
170+ private set
136171 var version by mutableStateOf("")
137172 private set
138173
@@ -175,7 +210,7 @@ class HostState(saved: SavedHost, private val app: AppState) {
175210
176211 fun connect(deviceName: String) {
177212 connection?.stop()
178- val connection = Connection(saved.address, saved.key, deviceName, ::onStatus, ::onMessage)
213+ val connection = Connection({ saved.addresses }, saved.key, deviceName, ::onStatus, ::onMessage) { activeAddress = it }
179214 this.connection = connection
180215 connection.start()
181216 }
@@ -197,6 +232,13 @@ class HostState(saved: SavedHost, private val app: AppState) {
197232
198233 fun send(message: ClientMessage): Boolean = connection?.send(message) ?: false
199234
235+ /** Keeps the routes a host reports, replacing the ones it reported before. Older hosts report none. */
236+ private fun learnRoutes(routes: List<Route>) {
237+ if (routes.isEmpty()) return
238+ val learned = routes.filter { it.kind != "loopback" }.map { it.copy(learned = true) }
239+ if (learned != saved.routes) update(saved.copy(routes = learned))
240+ }
241+
200242 private fun onStatus(status: ConnectionStatus) {
201243 this.status = status
202244 if (status is ConnectionStatus.Connected) {
@@ -250,7 +292,9 @@ class HostState(saved: SavedHost, private val app: AppState) {
250292 favorites = message.favorites
251293 }
252294 is ServerMessage.DiffReply -> diffRequests.remove(message.request)?.complete(message)
253- is ServerMessage.Welcome, is ServerMessage.Denied -> {}
295+ is ServerMessage.Welcome -> learnRoutes(message.routes)
296+ is ServerMessage.Routes -> learnRoutes(message.routes)
297+ is ServerMessage.Denied -> {}
254298 }
255299 }
256300
+28−4android/app/src/main/java/cc/vectrum/code/net/Connection.ktView File
@@ -25,14 +25,17 @@ sealed interface ConnectionStatus {
2525 /**
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
28- * when the connection drops.
28+ * 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.
2931 */
3032 class Connection(
31- private val address: String,
33+ private val addresses: () -> List<String>,
3234 private val key: String,
3335 private val deviceName: String,
3436 private val onStatus: (ConnectionStatus) -> Unit,
3537 private val onMessage: (ServerMessage) -> Unit,
38+ private val onAddress: (String) -> Unit = {},
3639 ) {
3740 private val main = Handler(Looper.getMainLooper())
3841 private var socket: WebSocket? = null
@@ -41,6 +44,9 @@ class Connection(
4144 private var attempt = 0
4245 private var generation = 0
4346
47+ /** Which of [addresses] this attempt dials. */
48+ private var route = 0
49+
4450 fun start() {
4551 stopped = false
4652 open()
@@ -62,6 +68,7 @@ class Connection(
6268 socket?.cancel()
6369 socket = null
6470 attempt = 0
71+ route = 0
6572 open()
6673 }
6774
@@ -78,7 +85,14 @@ class Connection(
7885 val gen = ++generation
7986 welcomed = false
8087 onStatus(ConnectionStatus.Connecting)
81- val request = Request.Builder().url("ws://${withPort(address)}/").build()
88+ val list = addresses()
89+ if (list.isEmpty()) {
90+ onStatus(ConnectionStatus.Offline("No address to reach this computer at."))
91+ return
92+ }
93+ val address = withPort(list[route.coerceAtMost(list.lastIndex)])
94+ onAddress(address)
95+ val request = Request.Builder().url("ws://$address/").build()
8296 socket = client.newWebSocket(
8397 request,
8498 object : WebSocketListener() {
@@ -95,6 +109,8 @@ class Connection(
95109 welcomed = true
96110 attempt = 0
97111 onStatus(ConnectionStatus.Connected(message.host, message.version))
112+ // Its routes, for the next time.
113+ onMessage(message)
98114 }
99115 is ServerMessage.Denied -> {
100116 stopped = true
@@ -117,8 +133,16 @@ class Connection(
117133 private fun dropped(gen: Int, reason: String) {
118134 main.post {
119135 if (gen != generation || stopped) return@post
136+ val wasWelcomed = welcomed
120137 welcomed = false
121138 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
144+ }
145+ route = 0
122146 onStatus(ConnectionStatus.Offline(reason))
123147 val delay = RETRY_DELAYS[attempt.coerceAtMost(RETRY_DELAYS.lastIndex)]
124148 attempt++
@@ -130,7 +154,7 @@ class Connection(
130154 private val RETRY_DELAYS = longArrayOf(1_000, 2_000, 4_000, 8_000, 15_000)
131155
132156 private val client = OkHttpClient.Builder()
133- .connectTimeout(8, TimeUnit.SECONDS)
157+ .connectTimeout(5, TimeUnit.SECONDS)
134158 .readTimeout(0, TimeUnit.SECONDS)
135159 .pingInterval(0, TimeUnit.SECONDS)
136160 .build()
+36−6android/app/src/main/java/cc/vectrum/code/net/Protocol.ktView File
@@ -444,7 +444,17 @@ data class RemoteFileDiff(val path: String = "", val binary: Boolean = false, va
444444 @Serializable
445445 sealed class ServerMessage {
446446 @Serializable @SerialName("welcome")
447- data class Welcome(val host: String = "", val version: String = "", val protocol: Int = 0) : ServerMessage()
447+ data class Welcome(
448+ val host: String = "",
449+ val version: String = "",
450+ val protocol: Int = 0,
451+ /** Where else the host listens. Older hosts don't say. */
452+ val routes: List<Route> = emptyList(),
453+ ) : ServerMessage()
454+
455+ /** The host's addresses changed, as when it joined another Wi-Fi. */
456+ @Serializable @SerialName("routes")
457+ data class Routes(val routes: List<Route> = emptyList()) : ServerMessage()
448458
449459 @Serializable @SerialName("denied")
450460 data class Denied(val reason: String = "") : ServerMessage()
@@ -517,13 +527,33 @@ fun parseServerMessage(text: String): ServerMessage? = try {
517527
518528 fun encode(message: ClientMessage): String = WireJson.encodeToString(ClientMessage.serializer(), message)
519529
520- /** A pairing code bundles a host's address and key: `192.168.2.10:47823/<key>`. */
521- fun parsePairing(code: String): Pair<String, String?> {
530+ /**
531+ * One way to reach a host: `192.168.2.10:47823` on the LAN, a Tailscale or
532+ * other VPN address. `kind`: `loopback`, `lan`, `tailnet`, `vpn` or `other`.
533+ */
534+ @Serializable
535+ data class Route(val address: String = "", val kind: String = "lan", val learned: Boolean = false)
536+
537+ /** How fast a route likely is, fastest first, as the desktop ranks them. */
538+ fun routeRank(kind: String): Int = when (kind) {
539+ "loopback" -> 0
540+ "lan" -> 1
541+ "tailnet" -> 2
542+ "vpn" -> 3
543+ else -> 4
544+ }
545+
546+ /**
547+ * A pairing code bundles a host's addresses and key, every address it listens
548+ * on comma-separated: `192.168.2.10:47823,100.101.1.2:47823/<key>`. Returns the
549+ * addresses (or the one address typed) and the key, if there is one.
550+ */
551+ fun parsePairing(code: String): Pair<List<String>, String?> {
522552 val trimmed = code.trim().removePrefix("ws://").removePrefix("vectrum://")
523553 val slash = trimmed.indexOf('/')
524- if (slash < 0) return trimmed to null
525- val key = trimmed.substring(slash + 1).trim()
526- return trimmed.substring(0, slash) to key.ifEmpty { null }
554+ val (addresses, key) = if (slash < 0) trimmed to null
555+ else trimmed.substring(0, slash) to trimmed.substring(slash + 1).trim().ifEmpty { null }
556+ return addresses.split(',').map { it.trim() }.filter { it.isNotEmpty() } to key
527557 }
528558
529559 /** `host` or `host:port`, with the default port filled in. */
+1−1android/app/src/main/java/cc/vectrum/code/ui/device/DeviceView.ktView File
@@ -98,7 +98,7 @@ fun DeviceView(
9898 val p = palette
9999 val context = LocalContext.current
100100 val scope = rememberCoroutineScope()
101- val stream = remember(device.id, host.saved.address, host.saved.key) { DeviceStream(host.saved.address, host.saved.key, device) }
101+ val stream = remember(device.id, host.activeAddress, host.saved.key) { DeviceStream(host.activeAddress, host.saved.key, device) }
102102 DisposableEffect(stream) {
103103 stream.start()
104104 onDispose { stream.stop() }
+1−1android/app/src/main/java/cc/vectrum/code/ui/pair/AddProject.ktView File
@@ -161,7 +161,7 @@ fun AddProjectSheet(app: AppState, host: HostState?, open: Boolean, onDismiss: (
161161 private fun HostList(app: AppState, onPick: (HostState) -> Unit) {
162162 for (host in app.hosts.toList()) {
163163 val detail = when (host.status) {
164- is ConnectionStatus.Connected -> host.saved.address
164+ is ConnectionStatus.Connected -> host.activeAddress
165165 ConnectionStatus.Connecting -> "Connecting…"
166166 else -> "Not connected"
167167 }
+7−8android/app/src/main/java/cc/vectrum/code/ui/pair/PairScreen.ktView File
@@ -40,7 +40,6 @@ import cc.vectrum.code.data.AppState
4040 import cc.vectrum.code.data.HostState
4141 import cc.vectrum.code.net.ConnectionStatus
4242 import cc.vectrum.code.net.parsePairing
43- import cc.vectrum.code.net.withPort
4443 import cc.vectrum.code.theme.Cap
4544 import cc.vectrum.code.theme.CapKind
4645 import cc.vectrum.code.theme.CapSize
@@ -76,24 +75,24 @@ fun PairScreen(app: AppState, nav: Nav) {
7675 var key by remember { mutableStateOf(TextFieldValue("")) }
7776 var error by remember { mutableStateOf<String?>(null) }
7877 val host = Pairing.host?.let { app.host(it) }
79- val (address, codeKey) = parsePairing(code.text)
80- val needsKey = address.isNotBlank() && codeKey == null
78+ val (addresses, codeKey) = parsePairing(code.text)
79+ val needsKey = addresses.isNotEmpty() && codeKey == null
8180 val onPairScreen = nav.screen == Screen.Pair
8281
8382 // The pairing is forgotten once this screen is left for another.
8483 DisposableEffect(Unit) { onDispose { if (nav.screen != Screen.Pair) Pairing.host = null } }
8584
8685 fun connect(text: String = code.text, typedKey: String = key.text) {
87- val (addr, fromCode) = parsePairing(text)
86+ val (found, fromCode) = parsePairing(text)
8887 val secret = typedKey.trim().ifEmpty { null } ?: fromCode
89- if (addr.isBlank() || secret == null) {
88+ if (found.isEmpty() || secret == null) {
9089 error = "Enter the other device's pairing code, or its address and key."
9190 return
9291 }
9392 error = null
9493 // On Home the screen goes as soon as there's a host; the status shows on the Pair screen.
9594 if (!onPairScreen) nav.go(Screen.Pair)
96- Pairing.host = app.addHost(withPort(addr), secret).saved.id
95+ Pairing.host = app.addHost(found, secret).saved.id
9796 }
9897
9998 LaunchedEffect(host?.status) {
@@ -185,7 +184,7 @@ fun PairScreen(app: AppState, nav: Nav) {
185184 label = "Connect",
186185 icon = Lucide.Plug,
187186 kind = CapKind.Primary,
188- enabled = address.isNotBlank() && (!needsKey || key.text.isNotBlank()),
187+ enabled = addresses.isNotEmpty() && (!needsKey || key.text.isNotBlank()),
189188 ) { connect() }
190189 }
191190 if (host != null) PairStatus(host)
@@ -246,7 +245,7 @@ private fun Steps() {
246245 private fun PairStatus(host: HostState) {
247246 val p = palette
248247 val (led, title, detail) = when (val status = host.status) {
249- ConnectionStatus.Connecting -> Triple(Led.Waiting, "Connecting…", withPort(host.saved.address))
248+ ConnectionStatus.Connecting -> Triple(Led.Waiting, "Connecting…", host.activeAddress)
250249 is ConnectionStatus.Connected -> Triple(Led.Live, "Connected to ${status.host}", "Opening its threads…")
251250 is ConnectionStatus.Denied -> Triple(Led.Failed, status.reason, "Check the key, or copy the pairing code again.")
252251 is ConnectionStatus.Offline -> Triple(
+1−2android/app/src/main/java/cc/vectrum/code/ui/settings/ConnectionsPage.ktView File
@@ -12,7 +12,6 @@ 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.withPort
1615 import cc.vectrum.code.theme.Cap
1716 import cc.vectrum.code.theme.CapKind
1817 import cc.vectrum.code.theme.CapSize
@@ -76,7 +75,7 @@ private fun HostRow(app: AppState, host: HostState) {
7675 var removing by remember { mutableStateOf(false) }
7776 SettingRow(
7877 host.name,
79- description = "${withPort(host.saved.address)} · $text",
78+ description = "${host.activeAddress} · $text",
8079 inline = true,
8180 titleSuffix = {
8281 Row(horizontalArrangement = Arrangement.spacedBy(6.dp), verticalAlignment = Alignment.CenterVertically) {
+29−0android/app/src/test/java/cc/vectrum/code/net/PairingTest.ktaddedView File
@@ -0,0 +1,29 @@
1+ package cc.vectrum.code.net
2+
3+ import cc.vectrum.code.data.SavedHost
4+ import org.junit.Assert.assertEquals
5+ import org.junit.Test
6+
7+ class PairingTest {
8+ @Test
9+ fun pairingCodes() {
10+ assertEquals(listOf("192.168.2.10:47823") to "abc", parsePairing("192.168.2.10:47823/abc"))
11+ assertEquals(
12+ listOf("192.168.2.10:47823", "100.101.1.2:47823") to "abc",
13+ parsePairing("vectrum://192.168.2.10:47823, 100.101.1.2:47823/abc"),
14+ )
15+ assertEquals(listOf("10.0.0.2") to null, parsePairing(" 10.0.0.2 "))
16+ assertEquals("10.0.0.2:$DEFAULT_PORT", withPort("10.0.0.2"))
17+ assertEquals("[::1]:9000", withPort("[::1]:9000"))
18+ }
19+
20+ /** The LAN first, then Tailscale, then other VPNs, with what the host reported added. */
21+ @Test
22+ fun routesFastestFirst() {
23+ val host = SavedHost(
24+ address = "100.101.1.2:47823,192.168.2.10:47823",
25+ routes = listOf(Route("100.90.0.1:47823", "tailnet", learned = true), Route("192.168.2.10:47823", "lan", learned = true)),
26+ )
27+ assertEquals(listOf("192.168.2.10:47823", "100.90.0.1:47823", "100.101.1.2:47823"), host.addresses)
28+ }
29+ }