app: net.captive_portal probe — reproduce Android's internet/portal checks

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 <noreply@anthropic.com>
This commit is contained in:
mrambossek
2026-07-31 23:01:22 +02:00
co-authored by Claude Opus 5
parent aaed22dd3f
commit 6e269d424b
4 changed files with 147 additions and 1 deletions
@@ -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<NetworkInventory.Entry>) : 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<String, ProbeResult>()
// 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()
}
}
}