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
@@ -15,6 +15,7 @@
|
||||
android:allowBackup="false"
|
||||
android:label="Echolot"
|
||||
android:supportsRtl="true"
|
||||
android:usesCleartextTraffic="true"
|
||||
android:theme="@style/Theme.Echolot">
|
||||
|
||||
<activity
|
||||
|
||||
@@ -11,6 +11,7 @@ import androidx.compose.runtime.setValue
|
||||
import androidx.lifecycle.AndroidViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import app.echo_lot.measurement.*
|
||||
import app.echo_lot.probe.CaptivePortalProbe
|
||||
import app.echo_lot.probe.IcmpProbe
|
||||
import app.echo_lot.probe.LinkSnapshotProbe
|
||||
import app.echo_lot.probe.NetworkInventory
|
||||
@@ -69,6 +70,7 @@ class RunViewModel(app: Application) : AndroidViewModel(app) {
|
||||
LinkSnapshotProbe(entries),
|
||||
IcmpProbe(entries, v6 = false),
|
||||
IcmpProbe(entries, v6 = true),
|
||||
CaptivePortalProbe(entries),
|
||||
)
|
||||
|
||||
val tests = ArrayList<Test>()
|
||||
@@ -132,6 +134,29 @@ class RunViewModel(app: Application) : AndroidViewModel(app) {
|
||||
val out = ArrayList<Finding>()
|
||||
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(
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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