diff --git a/docs/measurement-schema.md b/docs/measurement-schema.md index 500c8e2..0fbe79c 100644 --- a/docs/measurement-schema.md +++ b/docs/measurement-schema.md @@ -111,12 +111,24 @@ A run may exercise several networks simultaneously (Wi-Fi + cellular + USB ether "changes": [ { "at_mono_ns": 91000000000, "kind": "lost | gained | link_changed", "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". +`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[]` ```json diff --git a/echolot-app/app/build.gradle.kts b/echolot-app/app/build.gradle.kts index 0e50557..e8771b2 100644 --- a/echolot-app/app/build.gradle.kts +++ b/echolot-app/app/build.gradle.kts @@ -15,7 +15,7 @@ plugins { // // 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. -val appVersionName = "0.2.3" +val appVersionName = "0.2.4" fun versionCodeOf(semver: String): Int { val (major, minor, patch) = semver.substringBefore('-').split(".").map(String::toInt) diff --git a/echolot-app/app/src/main/kotlin/app/echo_lot/app/MainActivity.kt b/echolot-app/app/src/main/kotlin/app/echo_lot/app/MainActivity.kt index 36f26ed..23d68a2 100644 --- a/echolot-app/app/src/main/kotlin/app/echo_lot/app/MainActivity.kt +++ b/echolot-app/app/src/main/kotlin/app/echo_lot/app/MainActivity.kt @@ -432,9 +432,9 @@ private fun Results(doc: MeasurementDocument) { "Disconnect the VPN and run again to measure the networks themselves.") constraints.perNetworkBlocked -> "Some networks could not be measured" to - ("Android refused sends on $blocked — the restriction a VPN leaves in place " + - "while it tears down. These networks went unmeasured; wait a few " + - "seconds and run again.") + ("Android refused this app permission to send on $blocked, so they went " + + "unmeasured. A connected VPN is the usual cause; the refusal can also " + + "outlast one. Everything else in this run is unaffected.") else -> "A VPN holds the default route" to ("Default-route results describe the tunnel; per-network measurements " + diff --git a/echolot-app/app/src/main/kotlin/app/echo_lot/app/RunViewModel.kt b/echolot-app/app/src/main/kotlin/app/echo_lot/app/RunViewModel.kt index b770340..95396e7 100644 --- a/echolot-app/app/src/main/kotlin/app/echo_lot/app/RunViewModel.kt +++ b/echolot-app/app/src/main/kotlin/app/echo_lot/app/RunViewModel.kt @@ -594,10 +594,11 @@ class RunViewModel(app: Application) : AndroidViewModel(app) { runConstraints.perNetworkBlocked -> "The OS refused sends on $blocked" to ("Android denied this app permission to send on $blocked (EPERM on " + - "bind). That is the restriction a VPN imposes on the networks " + - "beneath it — a tunnel disconnected moments ago can still leave it " + - "in place while it tears down. Nothing in this run says anything " + - "about $blocked; wait a few seconds and run again.") + "bind), so nothing in this run says anything about it. The usual " + + "cause is a VPN — Android walls off the networks beneath a tunnel — " + + "but the refusal can also outlast one, and some networks are " + + "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 -> "A VPN holds the default route" to ("Everything using the default route in this run describes the tunnel, " + diff --git a/echolot-app/core-measurement/src/main/kotlin/app/echo_lot/measurement/Network.kt b/echolot-app/core-measurement/src/main/kotlin/app/echo_lot/measurement/Network.kt index 75fee57..9bfc46e 100644 --- a/echolot-app/core-measurement/src/main/kotlin/app/echo_lot/measurement/Network.kt +++ b/echolot-app/core-measurement/src/main/kotlin/app/echo_lot/measurement/Network.kt @@ -19,6 +19,17 @@ data class Network( val cellular: Cellular? = null, val changes: List = emptyList(), @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, ) /** diff --git a/echolot-app/core-probe/src/main/kotlin/app/echo_lot/probe/ConstraintDetector.kt b/echolot-app/core-probe/src/main/kotlin/app/echo_lot/probe/ConstraintDetector.kt index e43b47b..0ab3753 100644 --- a/echolot-app/core-probe/src/main/kotlin/app/echo_lot/probe/ConstraintDetector.kt +++ b/echolot-app/core-probe/src/main/kotlin/app/echo_lot/probe/ConstraintDetector.kt @@ -40,6 +40,12 @@ object ConstraintDetector { for (e in entries) { // The tunnel itself stays bindable — it is the underlying networks the OS walls off. 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 { DatagramSocket().use { s -> e.handle.bindSocket(s) } null diff --git a/echolot-app/core-probe/src/main/kotlin/app/echo_lot/probe/NetworkInventory.kt b/echolot-app/core-probe/src/main/kotlin/app/echo_lot/probe/NetworkInventory.kt index 069ccd8..c8d150e 100644 --- a/echolot-app/core-probe/src/main/kotlin/app/echo_lot/probe/NetworkInventory.kt +++ b/echolot-app/core-probe/src/main/kotlin/app/echo_lot/probe/NetworkInventory.kt @@ -65,6 +65,20 @@ object NetworkInventory { /** @SystemApi NetworkCapabilities.NET_CAPABILITY_PARTIAL_CONNECTIVITY, API 28+. */ 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 { val transport = when { caps.hasTransport(NetworkCapabilities.TRANSPORT_WIFI) -> Transport.WIFI @@ -99,6 +113,7 @@ object NetworkInventory { id = id, transport = transport, iface = lp.interfaceName, link = Link(mtu = lp.mtu.takeIf { it > 0 }, addresses = addresses, routes = routes, dns = dns), systemVerdict = systemVerdict(caps), + appUsable = appUsable(caps), ) } }