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:
co-authored by
Claude Opus 5
parent
d420a71a01
commit
ab6e278272
@@ -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
|
||||||
|
|||||||
@@ -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),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user