app: tell a broken device resolver apart from a broken network
Diagnosing a tablet that claimed "no internet" took twenty adb commands to establish something the app should have said in one run: ping to 1.1.1.1 worked, the configured DNS server answered a raw UDP query in 65 bytes, and Android still could not resolve a hostname. The network was fine; netd had wedged. Those two failures look identical to a user and want opposite responses — "look at your router" against "toggle your wifi" — so dns.resolver asks the network's own servers directly and compares the answer against what the platform returns for the same name. The query is hand-rolled over a plain DatagramSocket on purpose: anything routed through a resolver API would inherit the very fault being looked for. dns.system_resolver_broken fires only on the pairing that is otherwise unattributable: server answered, platform did not. Per network, because a phone can have wedged wifi and working cellular at once. Also records Android's own verdict per network — validated, captive portal, partial connectivity — which the app reproduced with its own HTTP probes but never stored. It is free, it is what the user sees in the status bar, and its disagreement with our measurements is exactly what identified the tablet. NET_CAPABILITY_PARTIAL_CONNECTIVITY is @SystemApi so the constant is inlined with its rationale, in the manner of OsAbi.kt, and read defensively enough to report unknown rather than false. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5
parent
40e76c52ca
commit
d65dbbc75a
+20
-1
@@ -222,6 +222,25 @@ object FindingRegistry {
|
||||
* behaviour. What is not acceptable is a run that quietly measures nothing and calls the
|
||||
* result healthy, so this says plainly which networks went unmeasured and why.
|
||||
*/
|
||||
/**
|
||||
* The network's DNS server answers, but this device cannot resolve through it.
|
||||
*
|
||||
* Worth separating from every other DNS failure because the remedy is somewhere else entirely.
|
||||
* A name that will not resolve looks identical to a user whatever the cause, and the two causes
|
||||
* pull in opposite directions: a server that does not answer means the network is broken and
|
||||
* the router is the thing to examine, while a server that answers a direct query on a device
|
||||
* that still cannot resolve means the platform resolver has wedged — fixed by toggling wifi,
|
||||
* and nothing to do with the network at all.
|
||||
*
|
||||
* Proven rather than inferred: the probe sends its own UDP query, bypassing the component under
|
||||
* suspicion, and compares that against what the platform returns for the same name.
|
||||
*/
|
||||
val DNS_SYSTEM_RESOLVER_BROKEN = FindingSpec(
|
||||
"dns.system_resolver_broken", Category.DNS, Severity.HIGH,
|
||||
"The network's DNS server answers, but this device cannot resolve names through it.",
|
||||
rulesOut = "A network fault: the server replied to a query sent from this device.",
|
||||
)
|
||||
|
||||
val MEASUREMENT_VPN_CONSTRAINED = FindingSpec(
|
||||
"measurement.vpn_constrained", Category.CONNECTIVITY, Severity.INFO,
|
||||
"A VPN was active, so the networks underneath it could not be measured.",
|
||||
@@ -261,7 +280,7 @@ object FindingRegistry {
|
||||
NAT_UDP_REBINDING, NAT_SYMMETRIC,
|
||||
THROUGHPUT_NO_DELIVERY, THROUGHPUT_BELOW_OFFERED,
|
||||
DNS_ANSWER_REWRITTEN, DNS_AUTHORITATIVE_UNREACHABLE,
|
||||
MEASUREMENT_VPN_CONSTRAINED, V6_NO_DEFAULT_ROUTE, V6_ROUTE_WITHOUT_ADDRESS, V6_NO_ICMP_REPLY, V6_NOT_OFFERED,
|
||||
DNS_SYSTEM_RESOLVER_BROKEN, MEASUREMENT_VPN_CONSTRAINED, V6_NO_DEFAULT_ROUTE, V6_ROUTE_WITHOUT_ADDRESS, V6_NO_ICMP_REPLY, V6_NOT_OFFERED,
|
||||
)
|
||||
|
||||
private val byCode: Map<String, FindingSpec> = all.associateBy { it.code }
|
||||
|
||||
@@ -18,6 +18,28 @@ data class Network(
|
||||
val wifi: Wifi? = null,
|
||||
val cellular: Cellular? = null,
|
||||
val changes: List<NetworkChange> = emptyList(),
|
||||
@SerialName("system_verdict") val systemVerdict: SystemVerdict? = null,
|
||||
)
|
||||
|
||||
/**
|
||||
* What Android itself concluded about a network, as opposed to what we measured.
|
||||
*
|
||||
* Recorded because it is the verdict the user can see — the "no internet" warning in the status
|
||||
* bar — and because it is free: the platform has already done the work by the time a run starts.
|
||||
*
|
||||
* Its real value is disagreement. When Android says a network is unusable and our own probes reach
|
||||
* the internet regardless, the fault is in the device rather than the network, and that distinction
|
||||
* is the difference between "fix your router" and "toggle your wifi". Neither number alone can say
|
||||
* that; only the two together.
|
||||
*/
|
||||
@Serializable
|
||||
data class SystemVerdict(
|
||||
/** Android's own connectivity check passed. Null when the platform did not say. */
|
||||
val validated: Boolean? = null,
|
||||
/** Android believes a captive portal is intercepting this network. */
|
||||
@SerialName("captive_portal") val captivePortal: Boolean? = null,
|
||||
/** Some traffic works and some does not — Android's own hedge. */
|
||||
@SerialName("partial_connectivity") val partialConnectivity: Boolean? = null,
|
||||
)
|
||||
|
||||
@Serializable
|
||||
|
||||
@@ -89,6 +89,13 @@ object TestType {
|
||||
// dns
|
||||
const val DNS_RESOLVER_INVENTORY = "dns.resolver_inventory"
|
||||
const val DNS_CANARY = "dns.canary"
|
||||
/**
|
||||
* Does this device's own resolver work, as distinct from the network's DNS.
|
||||
*
|
||||
* Registry addition, v1.1. Kept apart from [DNS_CANARY], which asks whether answers are being
|
||||
* tampered with; this asks whether answers arrive at all, and where the failure sits.
|
||||
*/
|
||||
const val DNS_RESOLVER = "dns.resolver"
|
||||
const val DNS_INTERCEPTION = "dns.interception"
|
||||
const val DNS_TTL_INTEGRITY = "dns.ttl_integrity"
|
||||
const val DNS_ANSWER_INTEGRITY = "dns.answer_integrity"
|
||||
|
||||
Reference in New Issue
Block a user