diff --git a/Android/app/build.gradle.kts b/Android/app/build.gradle.kts index cdb40cf6..60d81262 100644 --- a/Android/app/build.gradle.kts +++ b/Android/app/build.gradle.kts @@ -11,8 +11,8 @@ android { applicationId = "com.archipelago.app" minSdk = 26 targetSdk = 35 - versionCode = 31 - versionName = "0.5.11" + versionCode = 32 + versionName = "0.5.12" vectorDrawables { useSupportLibrary = true diff --git a/Android/app/src/main/java/com/archipelago/app/data/ServerQrParser.kt b/Android/app/src/main/java/com/archipelago/app/data/ServerQrParser.kt index 1af28b16..465d8838 100644 --- a/Android/app/src/main/java/com/archipelago/app/data/ServerQrParser.kt +++ b/Android/app/src/main/java/com/archipelago/app/data/ServerQrParser.kt @@ -88,8 +88,18 @@ object ServerQrParser { private fun parseFips(uri: Uri): FipsPairInfo? { val npub = uri.getQueryParameter("fnpub")?.trim() - val host = uri.getQueryParameter("fhost")?.trim() + var host = uri.getQueryParameter("fhost")?.trim() if (npub.isNullOrBlank() || host.isNullOrBlank()) return null + // A .fips fhost is a dead dial hint: Android's system DNS can't + // resolve .fips, so every handshake send fails and first connect + // falls back to slow anchor discovery. If the QR's `url` host is a + // real address, dial that instead (QRs minted while the node UI was + // browsed over the mesh carry npub….fips here). + if (host.endsWith(".fips")) { + val urlHost = uri.getQueryParameter("url") + ?.let { runCatching { Uri.parse(it).host }.getOrNull() } + if (!urlHost.isNullOrBlank() && !urlHost.endsWith(".fips")) host = urlHost + } return FipsPairInfo( npub = npub, ula = uri.getQueryParameter("fip")?.trim().orEmpty(), diff --git a/Android/app/src/main/java/com/archipelago/app/fips/FipsPreferences.kt b/Android/app/src/main/java/com/archipelago/app/fips/FipsPreferences.kt index e4392529..3c78dec6 100644 --- a/Android/app/src/main/java/com/archipelago/app/fips/FipsPreferences.kt +++ b/Android/app/src/main/java/com/archipelago/app/fips/FipsPreferences.kt @@ -126,7 +126,7 @@ class FipsPreferences(private val context: Context) { if (peer.ip.isBlank() || peer.port <= 0) continue merged.put(JSONObject().apply { put("npub", peer.npub) - put("alias", peer.name.ifBlank { "Party phone" }) + put("alias", hostSafeAlias(peer.name.ifBlank { "party-phone" })) put("addresses", JSONArray().put(JSONObject().apply { put("transport", "udp") put("addr", "${peer.ip}:${peer.port}") @@ -145,7 +145,7 @@ class FipsPreferences(private val context: Context) { ) { merged.put(JSONObject().apply { put("npub", ARCHY_ANCHOR_NPUB) - put("alias", "Archipelago anchor") + put("alias", "archipelago-anchor") put("addresses", JSONArray().put(JSONObject().apply { put("transport", ARCHY_ANCHOR_TRANSPORT) put("addr", ARCHY_ANCHOR_ADDR) @@ -200,16 +200,19 @@ class FipsPreferences(private val context: Context) { val incoming = mutableListOf() incoming += JSONObject().apply { put("npub", info.npub) - put("alias", alias.ifBlank { "Archipelago" }) + put("alias", hostSafeAlias(alias.ifBlank { "Archipelago" })) val addresses = JSONArray() - if (info.udpPort > 0) { + // .fips hosts are unresolvable on Android (no system .fips DNS): + // storing one gives the mesh a dial target that fails every + // handshake and stalls first connect on anchor discovery. + if (info.udpPort > 0 && !info.host.endsWith(".fips")) { addresses.put(JSONObject().apply { put("transport", "udp") put("addr", "${info.host}:${info.udpPort}") put("priority", 10) }) } - if (info.tcpPort > 0) { + if (info.tcpPort > 0 && !info.host.endsWith(".fips")) { addresses.put(JSONObject().apply { put("transport", "tcp") put("addr", "${info.host}:${info.tcpPort}") @@ -220,9 +223,10 @@ class FipsPreferences(private val context: Context) { } for (anchor in info.anchors) { if (anchor.npub == info.npub) continue + if (anchor.addr.substringBeforeLast(":").endsWith(".fips")) continue incoming += JSONObject().apply { put("npub", anchor.npub) - put("alias", "Mesh anchor") + put("alias", "mesh-anchor") put("addresses", JSONArray().put(JSONObject().apply { put("transport", anchor.transport) put("addr", anchor.addr) @@ -237,7 +241,7 @@ class FipsPreferences(private val context: Context) { ) { incoming += JSONObject().apply { put("npub", ARCHY_ANCHOR_NPUB) - put("alias", "Archipelago anchor") + put("alias", "archipelago-anchor") put("addresses", JSONArray().put(JSONObject().apply { put("transport", ARCHY_ANCHOR_TRANSPORT) put("addr", ARCHY_ANCHOR_ADDR) @@ -258,3 +262,14 @@ class FipsPreferences(private val context: Context) { } } } + +/** + * Peer aliases feed the fips host map as `.fips` hostnames; anything + * that isn't a valid DNS label ("Framework PT" — the space) gets rejected and + * silently drops the peer from name resolution. Slug it instead of losing it. + */ +internal fun hostSafeAlias(alias: String): String = + alias.lowercase() + .replace(Regex("[^a-z0-9.-]+"), "-") + .trim('-', '.') + .ifBlank { "archipelago" } diff --git a/neode-ui/public/packages/archipelago-companion.apk b/neode-ui/public/packages/archipelago-companion.apk index bfa5d411..03617818 100644 Binary files a/neode-ui/public/packages/archipelago-companion.apk and b/neode-ui/public/packages/archipelago-companion.apk differ diff --git a/neode-ui/src/components/CompanionIntroOverlay.vue b/neode-ui/src/components/CompanionIntroOverlay.vue index a8f4526f..09ffe886 100644 --- a/neode-ui/src/components/CompanionIntroOverlay.vue +++ b/neode-ui/src/components/CompanionIntroOverlay.vue @@ -267,12 +267,20 @@ function isTailnetIp(host: string): boolean { * - a tailnet 100.x address (operator browsing over Tailscale — a scanned * QR carried one of these on 2026-07-22 and the companion sat there * dialing an IP the phone had no route to) + * - a .fips name or mesh ULA (operator browsing over the mesh — a scanned + * QR carried npub….fips as fhost on 2026-07-24; Android's system DNS + * can't resolve .fips, so the phone's direct dial died and first + * connect crawled through anchor discovery instead of the LAN) */ async function resolveServerUrl(): Promise { if (IS_DEMO) return DEMO_SERVER_URL const { hostname, origin } = window.location const phoneUnreachable = - hostname === 'localhost' || hostname === '127.0.0.1' || isTailnetIp(hostname) + hostname === 'localhost' || + hostname === '127.0.0.1' || + isTailnetIp(hostname) || + hostname.endsWith('.fips') || + hostname.includes(':') // IPv6 literal — the node's mesh ULA if (!phoneUnreachable) return origin try { const res = await rpcClient.call<{ mdns_hostname?: string; lan_ip?: string | null }>({