diff --git a/Android/app/build.gradle.kts b/Android/app/build.gradle.kts index 91d4a278..2786e3a2 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 = 24 - versionName = "0.5.4" + versionCode = 25 + versionName = "0.5.5" vectorDrawables { useSupportLibrary = true diff --git a/Android/app/src/main/java/com/archipelago/app/fips/ArchyVpnService.kt b/Android/app/src/main/java/com/archipelago/app/fips/ArchyVpnService.kt index 25a38d2c..c54ae4c1 100644 --- a/Android/app/src/main/java/com/archipelago/app/fips/ArchyVpnService.kt +++ b/Android/app/src/main/java/com/archipelago/app/fips/ArchyVpnService.kt @@ -10,10 +10,15 @@ import android.os.Build import android.util.Log import com.archipelago.app.MainActivity import com.archipelago.app.R +import com.archipelago.app.data.ServerPreferences import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.Job import kotlinx.coroutines.SupervisorJob import kotlinx.coroutines.cancel +import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.isActive import kotlinx.coroutines.launch /** @@ -27,6 +32,7 @@ import kotlinx.coroutines.launch class ArchyVpnService : VpnService() { private val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO) + private var warmerJob: Job? = null override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int { if (intent?.action == ACTION_STOP) { @@ -89,10 +95,57 @@ class ArchyVpnService : VpnService() { val fd = pfd.detachFd() val result = FipsNative.start(identity.secret, peersJson, fd) Log.i(TAG, "mesh start: $result") - if (result.contains("\"error\"")) shutdown() + if (result.contains("\"error\"")) { + shutdown() + } else { + startSessionWarmer() + } + } + + /** + * Pre-warm + keep-warm mesh sessions to every known node ULA. + * + * Discovery + first session through the public tree can take 15s+ + * (HANDOFF-2026-07-23 node diagnosis) — paying that cost here, the + * moment the tunnel is up, means the connect probe and WebView hit an + * established session instead of timing out on a cold one. The periodic + * touch afterwards keeps the session from idling out. Failed connects + * are expected and cheap; the attempt itself is what drives discovery. + */ + private fun startSessionWarmer() { + warmerJob?.cancel() + warmerJob = scope.launch { + val prefs = ServerPreferences(this@ArchyVpnService) + var round = 0 + while (isActive && FipsNative.isRunning()) { + val ulas = try { + prefs.savedServers.first().mapNotNull { it.meshIp.ifBlank { null } }.distinct() + } catch (_: Exception) { + emptyList() + } + for (ula in ulas) { + try { + java.net.Socket().use { s -> + s.connect( + java.net.InetSocketAddress(java.net.InetAddress.getByName(ula), 80), + 20_000, + ) + } + } catch (_: Exception) { + // Cold path / node away — the connect attempt still + // drove session establishment; try again next round. + } + } + round++ + // Aggressive for the first ~minute (session bring-up), then a + // slow keep-warm tick that costs nearly nothing. + delay(if (round < 12) 5_000 else 60_000) + } + } } private fun shutdown() { + warmerJob?.cancel() FipsNative.stop() stopForeground(STOP_FOREGROUND_REMOVE) stopSelf() diff --git a/Android/app/src/main/java/com/archipelago/app/ui/screens/ServerConnectScreen.kt b/Android/app/src/main/java/com/archipelago/app/ui/screens/ServerConnectScreen.kt index ab57fd3b..0eb1de37 100644 --- a/Android/app/src/main/java/com/archipelago/app/ui/screens/ServerConnectScreen.kt +++ b/Android/app/src/main/java/com/archipelago/app/ui/screens/ServerConnectScreen.kt @@ -185,13 +185,17 @@ fun ServerConnectScreen( useHttps = false, port = "", ) - // The tunnel + mesh route need a moment on cold start — and on - // a first-ever pairing the VPN consent dialog is on screen at - // the same time, so give the user time to tap it. - for (attempt in 0 until 6) { - if (attempt > 0) delay(2000) - reachable = testConnection(meshServer) - if (reachable) break + // Mesh discovery + first session can take 15s+ through the + // public tree (HANDOFF-2026-07-23 node diagnosis), and on a + // first-ever pairing the VPN consent dialog is on screen at + // the same time — so probe patiently inside a 60s budget with + // per-attempt timeouts wide enough to ride out TCP + // retransmit backoff. The VPN service pre-warms the session + // in parallel (ArchyVpnService.startSessionWarmer). + val deadline = System.currentTimeMillis() + 60_000 + while (!reachable && System.currentTimeMillis() < deadline) { + reachable = testConnection(meshServer, timeoutMs = 15_000) + if (!reachable) delay(3000) } } isConnecting = false @@ -673,8 +677,10 @@ private fun sanitizeAddress(input: String): String { .trimEnd('/') } -/** Test RPC connectivity. Accepts self-signed certs for local LAN servers. */ -private suspend fun testConnection(server: ServerEntry): Boolean { +/** Test RPC connectivity. Accepts self-signed certs for local LAN servers. + * [timeoutMs] is per-phase (connect / read) — mesh probes need far more + * patience than LAN ones (first session through the tree can take 15s+). */ +private suspend fun testConnection(server: ServerEntry, timeoutMs: Int = 5000): Boolean { return withContext(Dispatchers.IO) { try { val url = URL("${server.toUrl()}/rpc/v1") @@ -694,8 +700,8 @@ private suspend fun testConnection(server: ServerEntry): Boolean { } connection.requestMethod = "POST" - connection.connectTimeout = 5000 - connection.readTimeout = 5000 + connection.connectTimeout = timeoutMs + connection.readTimeout = timeoutMs connection.setRequestProperty("Content-Type", "application/json") connection.doOutput = true val body = """{"method":"server.echo","params":{"message":"ping"}}""" diff --git a/docs/HANDOFF-2026-07-23-companion-apk-deploy.md b/docs/HANDOFF-2026-07-23-companion-apk-deploy.md index 7e8153ef..7843d87b 100644 --- a/docs/HANDOFF-2026-07-23-companion-apk-deploy.md +++ b/docs/HANDOFF-2026-07-23-companion-apk-deploy.md @@ -127,6 +127,18 @@ Chain of findings, each verified live: - **Upstream:** report the direct-peer session-path asymmetry to jmcorgan/fips (0.4.1). +## App-side recommendations implemented (23:30–23:50, Mac agent — 0.5.5/vc25) + +- Connect probe: mesh ULA now probed inside a **60s budget** with 15s + per-phase timeouts (rides out TCP retransmit backoff), replacing the old + ~8s window. +- **Session pre-warm**: the VPN service starts probing every saved node ULA + the moment the tunnel is up (5s cadence for the first minute, then a 60s + keep-warm tick) — discovery/handshake cost is paid in the background, and + the session never idles out while the mesh is connected. +- (A phone-side ping test in this window still showed zero replies — that + measurement predated the vps2 daemon restart below and is superseded.) + ## RESOLVED — root cause was vps2's degraded daemon, NOT the public tree (23:45) The privatize-the-mesh recommendation above is WITHDRAWN. Final diagnosis: diff --git a/neode-ui/public/packages/archipelago-companion.apk b/neode-ui/public/packages/archipelago-companion.apk index 03563c93..10d295ec 100644 Binary files a/neode-ui/public/packages/archipelago-companion.apk and b/neode-ui/public/packages/archipelago-companion.apk differ