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:
co-authored by
Claude Opus 5
parent
aaed22dd3f
commit
6e269d424b
@@ -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()
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user