On Android 10 and later, an app that opens /proc/net/tcp gets an access denial instead of a socket table. The VPN interface does not care: packets still arrive, because the tunnel is a routing decision, not a file permission. What changed is the answer to a narrower question, "which application owns this connection?" The replacement is ConnectivityManager.getConnectionOwnerUid, the supported per-connection query, and choosing between the old habit and the new API depends on which of three jobs you actually have: receiving packets, attributing a flow or totalling usage.

Two ways to answer the owner question

Before API 29, a privileged or root process could read the kernel's socket tables, match the four-tuple and learn the owning UID. That gave a device-wide inventory in one file. From API 29 the filesystem view is restricted, and the supported route is a per-connection query with the protocol, the local address and the remote address.

QuestionLegacy socket tablesgetConnectionOwnerUid
Who owns this flow?Scan and match the tupleAsk once per tuple
Device-wide inventory?Yes, when readableNo, one flow at a time
Historical usage?NoNo; that is NetworkStatsManager
Works without root?No on modern releasesYes, for authorized callers

The comparison is not "old versus new". It is three different jobs that used to be satisfied by one readable file and are now satisfied by three different interfaces with three different contracts.

The local address is the app's endpoint

The method signature takes a protocol, a local socket address and a remote socket address. Local means the application's side of the connection, not whichever endpoint happens to be the packet source in the moment you inspect it. A packet travelling from the remote server back to the app has its source and destination reversed relative to that definition.

ConnectivityManager cm = getSystemService(ConnectivityManager.class);
int uid = cm.getConnectionOwnerUid(
    OsConstants.IPPROTO_TCP, appLocalAddress, appRemoteAddress);

This direction mistake is the most common reason a working query returns the wrong answer, or none. Log both endpoints before the call and confirm the pair against the connection the app actually opened. Address family matters too: an IPv4-mapped address and a native IPv6 address are different tuples, and so are TCP and UDP flows on the same ports.

Authorization and unknown results are part of the contract

The query is restricted to authorized callers, including the active VPN application for the relevant user. An unrelated monitoring app cannot assume it can enumerate another profile's connections, and an unauthorized call can throw a security exception rather than return a friendly error. Handle both outcomes explicitly.

A missing owner is not a package name. The flow may have closed between the packet and the query, the tuple may be incomplete, or the caller may simply not be allowed to know. Caching the first successful answer and reusing it later attributes a new connection to an old app, which is worse than reporting unknown.

When attribution fails, the traffic still needs a decision: block it where the policy requires that, allow only a scope you understand, or surface the gap. That fallback belongs in the design, not in a comment.

Accounting is a different question

NetworkStatsManager reports usage under its own access rules. It can tell you how much a UID transferred over a period; it cannot tell you the owner of an arbitrary live packet. Network capabilities describe a network's properties, not a directory of sockets. Mixing the three produces dashboards that look consistent and are not.

For the simple case of deciding which apps use the tunnel, Android's supported allowed and disallowed application lists already express the policy without scraping anything. Reserve per-flow attribution for the cases that genuinely need it, and budget for a maintained implementation that handles direction, permissions and unknown states together.

Pick the tool by job, then test the edges

If the requirement is tunnel selection, use the platform list. If it is live attribution, use the owner query and treat every unanswerable tuple as unknown. If it is historical totals, use the stats API. Then test the three edges that break naive implementations: a reverse-direction packet, a flow that closes before you query it, and a caller that is not the active VPN.

None of this requires disabling SELinux, rooting the phone or making kernel tables globally readable. The constraint is the point: the platform moved attribution behind a contract so that the answer is available to the component that legitimately needs it, and to nobody else.

Artaigilean co-cheangailte