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:allowBackup="false"
|
||||||
android:label="Echolot"
|
android:label="Echolot"
|
||||||
android:supportsRtl="true"
|
android:supportsRtl="true"
|
||||||
|
android:usesCleartextTraffic="true"
|
||||||
android:theme="@style/Theme.Echolot">
|
android:theme="@style/Theme.Echolot">
|
||||||
|
|
||||||
<activity
|
<activity
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ import androidx.compose.runtime.setValue
|
|||||||
import androidx.lifecycle.AndroidViewModel
|
import androidx.lifecycle.AndroidViewModel
|
||||||
import androidx.lifecycle.viewModelScope
|
import androidx.lifecycle.viewModelScope
|
||||||
import app.echo_lot.measurement.*
|
import app.echo_lot.measurement.*
|
||||||
|
import app.echo_lot.probe.CaptivePortalProbe
|
||||||
import app.echo_lot.probe.IcmpProbe
|
import app.echo_lot.probe.IcmpProbe
|
||||||
import app.echo_lot.probe.LinkSnapshotProbe
|
import app.echo_lot.probe.LinkSnapshotProbe
|
||||||
import app.echo_lot.probe.NetworkInventory
|
import app.echo_lot.probe.NetworkInventory
|
||||||
@@ -69,6 +70,7 @@ class RunViewModel(app: Application) : AndroidViewModel(app) {
|
|||||||
LinkSnapshotProbe(entries),
|
LinkSnapshotProbe(entries),
|
||||||
IcmpProbe(entries, v6 = false),
|
IcmpProbe(entries, v6 = false),
|
||||||
IcmpProbe(entries, v6 = true),
|
IcmpProbe(entries, v6 = true),
|
||||||
|
CaptivePortalProbe(entries),
|
||||||
)
|
)
|
||||||
|
|
||||||
val tests = ArrayList<Test>()
|
val tests = ArrayList<Test>()
|
||||||
@@ -132,6 +134,29 @@ class RunViewModel(app: Application) : AndroidViewModel(app) {
|
|||||||
val out = ArrayList<Finding>()
|
val out = ArrayList<Finding>()
|
||||||
val ids = RunIds()
|
val ids = RunIds()
|
||||||
for (t in tests) {
|
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) {
|
if (t.type == TestType.ICMP_PING6 && t.status == TestStatus.FAILED) {
|
||||||
out.add(
|
out.add(
|
||||||
Finding(
|
Finding(
|
||||||
|
|||||||
@@ -55,6 +55,8 @@ object TestType {
|
|||||||
const val LINK_SNAPSHOT = "link.snapshot"
|
const val LINK_SNAPSHOT = "link.snapshot"
|
||||||
const val LINK_DHCP_RENEWAL_WATCH = "link.dhcp_renewal_watch"
|
const val LINK_DHCP_RENEWAL_WATCH = "link.dhcp_renewal_watch"
|
||||||
const val LINK_IP_MONITOR = "link.ip_monitor"
|
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
|
// icmp
|
||||||
const val ICMP_PING4 = "icmp.ping4"
|
const val ICMP_PING4 = "icmp.ping4"
|
||||||
const val ICMP_PING6 = "icmp.ping6"
|
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. */
|
/** Maps a dotted test type to its §7.2 category for verdict rollup. */
|
||||||
fun category(type: String): Category = when (type.substringBefore('.')) {
|
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
|
"dns" -> Category.DNS
|
||||||
"nat" -> Category.NAT
|
"nat" -> Category.NAT
|
||||||
"mtu" -> Category.MTU
|
"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