app: an IMS network is not a blocked measurement

rmnet_data1 on the OnePlus is the carrier's IMS/VoLTE network: it carries
IMS&MMTEL but neither INTERNET nor NOT_RESTRICTED, so binding needs
CONNECTIVITY_USE_RESTRICTED_NETWORKS - signature-level, unobtainable.
EPERM there is permanent and says nothing about any network's health, but
the constraint detector counted it, so a phone with VoLTE reported
'measurement blocked' on every run and every verdict came out
INCONCLUSIVE. Capabilities now decide: networks an app may never bind are
recorded as app_usable:false in networks[] and skipped, rather than
reported as something the run failed to do.

Also retires the 'while it tears down' wording, which was a guess that
turned out to be wrong - the refusal outlasts the VPN indefinitely, so
the text now names the VPN as the usual cause without asserting a
timeline it cannot know.

App version 0.2.4.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
mrambossek
2026-08-02 14:11:08 +02:00
co-authored by Claude Opus 5
parent d420a71a01
commit ab6e278272
7 changed files with 54 additions and 9 deletions
+13 -1
View File
@@ -111,12 +111,24 @@ A run may exercise several networks simultaneously (Wi-Fi + cellular + USB ether
"changes": [ "changes": [
{ "at_mono_ns": 91000000000, "kind": "lost | gained | link_changed", { "at_mono_ns": 91000000000, "kind": "lost | gained | link_changed",
"detail": { /* new link snapshot or diff */ } } "detail": { /* new link snapshot or diff */ } }
] ],
"app_usable": true
} }
``` ```
`routes[].proto` and lifetime fields are Shizuku-tier data (`ip route`/`ip addr`); app-tier snapshots leave them absent — absence means "not observed", never "not present". `routes[].proto` and lifetime fields are Shizuku-tier data (`ip route`/`ip addr`); app-tier snapshots leave them absent — absence means "not observed", never "not present".
`app_usable` records whether an ordinary app may send on this network at all. Android lists the
carrier's special-purpose networks — IMS/VoLTE, MMS, XCAP — alongside the real ones, and they
carry neither `INTERNET` nor `NOT_RESTRICTED`; binding to one needs
`CONNECTIVITY_USE_RESTRICTED_NETWORKS`, which is signature-level and unobtainable for a normal
app. Those networks are therefore permanently unmeasurable, and that is a property of Android's
permission model rather than of the link. They stay in `networks[]` because they are genuinely
present — an interface silently missing from the inventory is its own kind of lie — but a
consumer must not read the absence of tests against them as a fault, and they are **not**
`constraints.unmeasured_networks` (§3): nothing was prevented, the run was never entitled to
measure them.
## 5. `server_sessions[]` ## 5. `server_sessions[]`
```json ```json
+1 -1
View File
@@ -15,7 +15,7 @@ plugins {
// //
// major*1_000_000 + minor*10_000 + patch*10 leaves room for 9 patch-level rebuilds (the trailing // major*1_000_000 + minor*10_000 + patch*10 leaves room for 9 patch-level rebuilds (the trailing
// digit) without disturbing the mapping, and stays inside the 2_100_000_000 ceiling until major 2100. // digit) without disturbing the mapping, and stays inside the 2_100_000_000 ceiling until major 2100.
val appVersionName = "0.2.3" val appVersionName = "0.2.4"
fun versionCodeOf(semver: String): Int { fun versionCodeOf(semver: String): Int {
val (major, minor, patch) = semver.substringBefore('-').split(".").map(String::toInt) val (major, minor, patch) = semver.substringBefore('-').split(".").map(String::toInt)
@@ -432,9 +432,9 @@ private fun Results(doc: MeasurementDocument) {
"Disconnect the VPN and run again to measure the networks themselves.") "Disconnect the VPN and run again to measure the networks themselves.")
constraints.perNetworkBlocked -> constraints.perNetworkBlocked ->
"Some networks could not be measured" to "Some networks could not be measured" to
("Android refused sends on $blocked — the restriction a VPN leaves in place " + ("Android refused this app permission to send on $blocked, so they went " +
"while it tears down. These networks went unmeasured; wait a few " + "unmeasured. A connected VPN is the usual cause; the refusal can also " +
"seconds and run again.") "outlast one. Everything else in this run is unaffected.")
else -> else ->
"A VPN holds the default route" to "A VPN holds the default route" to
("Default-route results describe the tunnel; per-network measurements " + ("Default-route results describe the tunnel; per-network measurements " +
@@ -594,10 +594,11 @@ class RunViewModel(app: Application) : AndroidViewModel(app) {
runConstraints.perNetworkBlocked -> runConstraints.perNetworkBlocked ->
"The OS refused sends on $blocked" to "The OS refused sends on $blocked" to
("Android denied this app permission to send on $blocked (EPERM on " + ("Android denied this app permission to send on $blocked (EPERM on " +
"bind). That is the restriction a VPN imposes on the networks " + "bind), so nothing in this run says anything about it. The usual " +
"beneath it — a tunnel disconnected moments ago can still leave it " + "cause is a VPN — Android walls off the networks beneath a tunnel — " +
"in place while it tears down. Nothing in this run says anything " + "but the refusal can also outlast one, and some networks are " +
"about $blocked; wait a few seconds and run again.") "reserved for the system entirely. If no VPN is connected, this is " +
"a restriction on the app rather than a fault in the network.")
else -> else ->
"A VPN holds the default route" to "A VPN holds the default route" to
("Everything using the default route in this run describes the tunnel, " + ("Everything using the default route in this run describes the tunnel, " +
@@ -19,6 +19,17 @@ data class Network(
val cellular: Cellular? = null, val cellular: Cellular? = null,
val changes: List<NetworkChange> = emptyList(), val changes: List<NetworkChange> = emptyList(),
@SerialName("system_verdict") val systemVerdict: SystemVerdict? = null, @SerialName("system_verdict") val systemVerdict: SystemVerdict? = null,
/**
* Whether an ordinary app may send on this network at all.
*
* False for the carrier's special-purpose networks — IMS/VoLTE, MMS, XCAP — which appear
* beside the real ones in Android's list and carry neither `INTERNET` nor `NOT_RESTRICTED`.
* Binding to those needs `CONNECTIVITY_USE_RESTRICTED_NETWORKS`, a privileged permission no
* normal app can hold, so the refusal is permanent and says nothing about the network's
* health. Recorded rather than hidden: a reader seeing an interface with no measurements
* against it deserves to know the OS forbade them, instead of concluding the link is dead.
*/
@SerialName("app_usable") val appUsable: Boolean? = null,
) )
/** /**
@@ -40,6 +40,12 @@ object ConstraintDetector {
for (e in entries) { for (e in entries) {
// The tunnel itself stays bindable — it is the underlying networks the OS walls off. // The tunnel itself stays bindable — it is the underlying networks the OS walls off.
if (e.model.transport == Transport.VPN) continue if (e.model.transport == Transport.VPN) continue
// Networks an app may never bind (carrier IMS/VoLTE, MMS, XCAP — no INTERNET, no
// NOT_RESTRICTED) refuse with EPERM permanently, VPN or no VPN. Counting them as a
// constraint is what made a phone with VoLTE report "measurement blocked" forever
// and forced every run INCONCLUSIVE. They are recorded in networks[] as
// app_usable:false; they are not something a run failed to do.
if (e.model.appUsable == false) continue
val err = try { val err = try {
DatagramSocket().use { s -> e.handle.bindSocket(s) } DatagramSocket().use { s -> e.handle.bindSocket(s) }
null null
@@ -65,6 +65,20 @@ object NetworkInventory {
/** @SystemApi NetworkCapabilities.NET_CAPABILITY_PARTIAL_CONNECTIVITY, API 28+. */ /** @SystemApi NetworkCapabilities.NET_CAPABILITY_PARTIAL_CONNECTIVITY, API 28+. */
private const val NET_CAPABILITY_PARTIAL_CONNECTIVITY = 24 private const val NET_CAPABILITY_PARTIAL_CONNECTIVITY = 24
/**
* Can an ordinary app send on this network?
*
* The carrier's special-purpose networks (IMS/VoLTE, MMS, XCAP) sit in `allNetworks` next to
* the real ones, carrying `IMS`/`MMS` but neither `INTERNET` nor `NOT_RESTRICTED`. Binding
* to them fails with EPERM forever, because it needs `CONNECTIVITY_USE_RESTRICTED_NETWORKS`
* — signature-level, unobtainable for this app. Asking the capabilities up front is what
* separates "the OS will never let us measure this" from "something is blocking us", which
* a bind attempt alone cannot distinguish and which the constraint logic must not confuse.
*/
private fun appUsable(caps: NetworkCapabilities): Boolean =
caps.hasCapability(NetworkCapabilities.NET_CAPABILITY_INTERNET) &&
caps.hasCapability(NetworkCapabilities.NET_CAPABILITY_NOT_RESTRICTED)
private fun toModel(id: String, caps: NetworkCapabilities, lp: LinkProperties): MNetwork { private fun toModel(id: String, caps: NetworkCapabilities, lp: LinkProperties): MNetwork {
val transport = when { val transport = when {
caps.hasTransport(NetworkCapabilities.TRANSPORT_WIFI) -> Transport.WIFI caps.hasTransport(NetworkCapabilities.TRANSPORT_WIFI) -> Transport.WIFI
@@ -99,6 +113,7 @@ object NetworkInventory {
id = id, transport = transport, iface = lp.interfaceName, id = id, transport = transport, iface = lp.interfaceName,
link = Link(mtu = lp.mtu.takeIf { it > 0 }, addresses = addresses, routes = routes, dns = dns), link = Link(mtu = lp.mtu.takeIf { it > 0 }, addresses = addresses, routes = routes, dns = dns),
systemVerdict = systemVerdict(caps), systemVerdict = systemVerdict(caps),
appUsable = appUsable(caps),
) )
} }
} }