From 6e269d424b4160e730dceabd75cc048a1daa3adf Mon Sep 17 00:00:00 2001 From: mrambossek Date: Fri, 31 Jul 2026 23:01:22 +0200 Subject: [PATCH] =?UTF-8?q?app:=20net.captive=5Fportal=20probe=20=E2=80=94?= =?UTF-8?q?=20reproduce=20Android's=20internet/portal=20checks?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Mirrors NetworkMonitor: per active network, fetch the AOSP default generate_204 endpoints and check for HTTP 204 No Content. - HTTPS https://www.google.com/generate_204 == 204 -> validated internet - HTTP http://connectivitycheck.gstatic.com/generate_204: 204 -> clean; an unfollowed 3xx or a 200-with-body -> captive portal (Location captured) - both fail -> no_internet Per-network verdicts (bound via Network.openConnection), redirects not followed (the 3xx IS the evidence). Findings: captive_portal (medium) and no_internet (high). New test type net.captive_portal (net family -> connectivity category). App gains usesCleartextTraffic (a network diagnostic that intentionally probes plain HTTP). Builds; measurement verdict tests still green. On-device verification deferred with the rest (flaky test devices). Co-Authored-By: Claude Opus 5 --- echolot-app/app/src/main/AndroidManifest.xml | 1 + .../kotlin/app/echo_lot/app/RunViewModel.kt | 25 ++++ .../kotlin/app/echo_lot/measurement/Test.kt | 4 +- .../app/echo_lot/probe/CaptivePortalProbe.kt | 118 ++++++++++++++++++ 4 files changed, 147 insertions(+), 1 deletion(-) create mode 100644 echolot-app/core-probe/src/main/kotlin/app/echo_lot/probe/CaptivePortalProbe.kt diff --git a/echolot-app/app/src/main/AndroidManifest.xml b/echolot-app/app/src/main/AndroidManifest.xml index d7046db..91f394c 100644 --- a/echolot-app/app/src/main/AndroidManifest.xml +++ b/echolot-app/app/src/main/AndroidManifest.xml @@ -15,6 +15,7 @@ android:allowBackup="false" android:label="Echolot" android:supportsRtl="true" + android:usesCleartextTraffic="true" android:theme="@style/Theme.Echolot"> () @@ -132,6 +134,29 @@ class RunViewModel(app: Application) : AndroidViewModel(app) { val out = ArrayList() val ids = RunIds() for (t in tests) { + if (t.type == TestType.NET_CAPTIVE_PORTAL) { + val ev = t.evidence?.toString() ?: "" + when { + ev.contains("\"captive_portal\"") -> out.add( + Finding( + id = ids.uuid(), code = "connectivity.captive_portal", category = Category.CONNECTIVITY, + severity = Severity.MEDIUM, confidence = Confidence.HIGH, + title = "Captive portal intercepting connections", + description = "The generate_204 check returned a redirect or a page instead of HTTP 204 — a captive portal (login/splash page) is intercepting traffic on this network.", + evidenceRefs = listOf(EvidenceRef(t.id)), + ) + ) + t.status == TestStatus.FAILED -> out.add( + Finding( + id = ids.uuid(), code = "connectivity.no_internet", category = Category.CONNECTIVITY, + severity = Severity.HIGH, confidence = Confidence.HIGH, + title = "No working internet on any network", + description = "Android's own generate_204 connectivity checks failed on every active network (no HTTP 204) — this device has no validated internet path.", + evidenceRefs = listOf(EvidenceRef(t.id)), + ) + ) + } + } if (t.type == TestType.ICMP_PING6 && t.status == TestStatus.FAILED) { out.add( Finding( diff --git a/echolot-app/core-measurement/src/main/kotlin/app/echo_lot/measurement/Test.kt b/echolot-app/core-measurement/src/main/kotlin/app/echo_lot/measurement/Test.kt index 322c83b..eccec5b 100644 --- a/echolot-app/core-measurement/src/main/kotlin/app/echo_lot/measurement/Test.kt +++ b/echolot-app/core-measurement/src/main/kotlin/app/echo_lot/measurement/Test.kt @@ -55,6 +55,8 @@ object TestType { const val LINK_SNAPSHOT = "link.snapshot" const val LINK_DHCP_RENEWAL_WATCH = "link.dhcp_renewal_watch" const val LINK_IP_MONITOR = "link.ip_monitor" + // net — connectivity validation (reproduces Android's NetworkMonitor generate_204 checks) + const val NET_CAPTIVE_PORTAL = "net.captive_portal" // icmp const val ICMP_PING4 = "icmp.ping4" const val ICMP_PING6 = "icmp.ping6" @@ -131,7 +133,7 @@ object TestType { /** Maps a dotted test type to its §7.2 category for verdict rollup. */ fun category(type: String): Category = when (type.substringBefore('.')) { - "link", "icmp", "trace", "traceroute", "train", "port", "time" -> Category.CONNECTIVITY + "link", "icmp", "trace", "traceroute", "train", "port", "time", "net" -> Category.CONNECTIVITY "dns" -> Category.DNS "nat" -> Category.NAT "mtu" -> Category.MTU diff --git a/echolot-app/core-probe/src/main/kotlin/app/echo_lot/probe/CaptivePortalProbe.kt b/echolot-app/core-probe/src/main/kotlin/app/echo_lot/probe/CaptivePortalProbe.kt new file mode 100644 index 0000000..f96994a --- /dev/null +++ b/echolot-app/core-probe/src/main/kotlin/app/echo_lot/probe/CaptivePortalProbe.kt @@ -0,0 +1,118 @@ +// SPDX-FileCopyrightText: 2026 Echolot contributors +// SPDX-License-Identifier: GPL-3.0-or-later + +package app.echo_lot.probe + +import android.content.Context +import android.net.Network +import app.echo_lot.measurement.Test +import app.echo_lot.measurement.TestStatus +import app.echo_lot.measurement.TestType +import app.echo_lot.measurement.Tier +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext +import kotlinx.serialization.json.JsonObject +import kotlinx.serialization.json.buildJsonObject +import kotlinx.serialization.json.put +import kotlinx.serialization.json.putJsonObject +import java.io.IOException +import java.net.HttpURLConnection +import java.net.URL + +/** + * Reproduces Android's own "do I have internet / is there a captive portal?" logic + * (NetworkMonitor): it fetches `generate_204` endpoints and checks for **HTTP 204 No Content**. + * + * Per active network (bound via Network.openConnection): + * - **HTTPS 204** (`https://www.google.com/generate_204`) → real validated internet. + * - **HTTP 204** (`http://connectivitycheck.gstatic.com/generate_204`) → a plain-HTTP path with + * no interference. A 3xx redirect or a 200-with-body instead of 204 is the classic **captive + * portal** signature (the portal's login page); the redirect Location is captured. + * - timeout/IO error on both → no working internet on that network. + * + * These are the AOSP default probe URLs (Settings.Global CAPTIVE_PORTAL_HTTPS_URL / + * CAPTIVE_PORTAL_HTTP_URL). Redirects are NOT followed — an unfollowed 3xx is the evidence. + */ +class CaptivePortalProbe(private val entries: List) : Probe { + override val type = TestType.NET_CAPTIVE_PORTAL + override val tier = Tier.APP + + private val httpsUrl = "https://www.google.com/generate_204" + private val httpUrl = "http://connectivitycheck.gstatic.com/generate_204" + + override suspend fun run(ctx: Context, ids: ProbeIds): Test = withContext(Dispatchers.IO) { + val b = TestBuilder(type, tier, ids) + val perNet = LinkedHashMap() + + // Default network first, then each active network explicitly. + perNet["default"] = validate(null) + for (e in entries) { + perNet["${e.model.transport.name.lowercase()}:${e.model.id}"] = validate(e.handle) + } + + val evidence: JsonObject = buildJsonObject { + put("https_url", httpsUrl); put("http_url", httpUrl) + for ((label, r) in perNet) putJsonObject(label) { + put("https_code", r.httpsCode); put("http_code", r.httpCode) + r.portalLocation?.let { put("portal_location", it) } + put("verdict", r.verdict) + } + } + // Best verdict across networks: validated > portal > none. + val anyValidated = perNet.values.any { it.verdict == "validated" } + val anyPortal = perNet.values.any { it.verdict == "captive_portal" } + val status = when { + anyValidated -> TestStatus.OK + anyPortal -> TestStatus.PARTIAL // reachable but intercepted + else -> TestStatus.FAILED // no working internet anywhere + } + b.build(status, evidence = evidence) + } + + private data class ProbeResult( + val httpsCode: Int, val httpCode: Int, val portalLocation: String?, val verdict: String, + ) + + private fun validate(network: Network?): ProbeResult { + val https = probe(network, httpsUrl) + val http = probe(network, httpUrl) + val portalLoc = http.location.takeIf { http.code in 300..399 } + val verdict = when { + https.code == 204 -> "validated" // real internet + http.code == 204 -> "validated_http_only" // HTTP clean, HTTPS blocked + http.code in 300..399 || (http.code == 200 && http.hadBody) -> "captive_portal" + https.code < 0 && http.code < 0 -> "no_internet" + else -> "inconclusive" + } + return ProbeResult(https.code, http.code, portalLoc, verdict) + } + + private data class Resp(val code: Int, val location: String?, val hadBody: Boolean) + + /** One probe: code (-1 on failure), Location header, and whether a body was present (204 has none). */ + private fun probe(network: Network?, urlStr: String): Resp { + var conn: HttpURLConnection? = null + return try { + val url = URL(urlStr) + conn = (network?.openConnection(url) ?: url.openConnection()) as HttpURLConnection + conn.instanceFollowRedirects = false // an unfollowed 3xx is the portal signal + conn.connectTimeout = 4000 + conn.readTimeout = 4000 + conn.requestMethod = "GET" + conn.setRequestProperty("User-Agent", "Echolot") + conn.setRequestProperty("Connection", "close") + val code = conn.responseCode + val loc = conn.getHeaderField("Location") + val body = runCatching { + (conn.inputStream ?: conn.errorStream)?.use { it.read() != -1 } + }.getOrNull() ?: false + Resp(code, loc, body) + } catch (e: IOException) { + Resp(-1, null, false) + } catch (e: Throwable) { + Resp(-1, null, false) + } finally { + conn?.disconnect() + } + } +}