The check most Android apps use to detect a VPN is an interface name, and it was never a VPN check. It reports that some interface exists with a string the developer expected, which on one device means OpenVPN and on another means something unrelated. A ping is the same kind of evidence: it proves that one destination answered, not that a tunnel carried the packet. What the platform actually exposes is a set of networks and the transports each one declares, which is a different question with a different answer.

Myth: if tun0 exists, a VPN is up

Android VPN apps are VpnService implementations that hand a tun file descriptor to the system. The interface name comes from whoever created it: OpenVPN commonly produces tun0, PPTP and L2TP produce ppp0, WireGuard names its interface after the tunnel, and an IPsec tunnel may not present a conventional name at all. Reading /proc/net/dev or enumerating NetworkInterface objects therefore searches for a convention, not for a fact.

Both error directions are live. A device can have tun0 for a local tunnel that has nothing to do with the user's VPN, and a perfectly healthy WireGuard tunnel can be invisible to a check that only knows the string tun0. False positives and false negatives in the same function make it unusable as a diagnostic, because a support ticket that says "no VPN detected" cannot be trusted in either direction.

Access has also narrowed. Newer Android releases restrict what an ordinary app can read from /proc, so a name-based check that worked on Android 8 can return an empty list on a later device without any code change. When the check silently fails open, the app concludes there is no VPN on hardware where the tunnel is fine.

Myth: a successful ping means the tunnel is carrying traffic

A ping to an internal host succeeds whenever a route to that host exists. With split tunnelling, the host may be reachable over Wi-Fi while the tunnel is up and doing something else entirely. With a per-app VPN, the app doing the pinging may be excluded from the tunnel, so the ICMP echo leaves outside it and comes back, which proves the opposite of what the check assumes.

ICMP and TCP are also filtered separately. A tunnel that permits echo requests while dropping the TCP port a service needs will pass the ping test and fail the application, and that is a firewall rule on the far side rather than a property of the device. The only honest reading of a successful ping is that this destination answered this protocol at this moment.

What the platform actually reports

From API 21 onward, ConnectivityManager.getAllNetworks() returns every network the system knows about, and getNetworkCapabilities(network) reports what that network declares. A VPN network carries TRANSPORT_VPN. Looping over all networks answers one question: is any VPN currently up on this device?

A second, narrower question needs a different call. getActiveNetwork() returns the network the app's traffic would use right now, and checking the same capability on it answers: would my traffic go through a VPN? The two can disagree, and the disagreement is informative. A VPN that is up but not active points at per-app routing or a split-tunnel configuration, not at a missing tunnel.

Network active = cm.getActiveNetwork();
NetworkCapabilities caps = cm.getNetworkCapabilities(active);
boolean viaVpn = caps != null
    && caps.hasTransport(NetworkCapabilities.TRANSPORT_VPN);

Guard both calls. Capabilities can be null for a network that disappeared between listing and querying, and the active network can be null during a handover. NET_CAPABILITY_NOT_VPN is useful in the other direction, to confirm that a network is definitely not a tunnel rather than merely lacking the VPN transport flag.

NetworkInfo was the same myth with an API

ConnectivityManager.TYPE_VPN with NetworkInfo is the second generation of this check, and it is deprecated for a reason. On modern releases the object returned for a VPN can be null while the tunnel is connected, so code that treats null as "no VPN" quietly reports a false negative on the devices where the answer matters most.

The broadcast that usually accompanies it is no better. CONNECTIVITY_CHANGE has a documented history of missing VPN transitions, including builds where it fired only when a VPN connected or disconnected rather than on every network change. A listener built on it produces a state variable that drifts away from the platform's view until the next full reconnect.

React to callbacks instead of polling

Register a NetworkRequest that requires TRANSPORT_VPN and removes NET_CAPABILITY_NOT_VPN, then registerNetworkCallback with that request. onAvailable and onLost then tell you when the declaration changes, which is the event you actually care about. Unregister the callback when the component stops, or the app keeps a reference to a dead screen and the callback keeps firing.

NetworkRequest req = new NetworkRequest.Builder()
    .addTransportType(NetworkCapabilities.TRANSPORT_VPN)
    .removeCapability(NetworkCapabilities.NET_CAPABILITY_NOT_VPN)
    .build();
cm.registerNetworkCallback(req, callback);

This needs ACCESS_NETWORK_STATE, which is a normal permission: no root, no user consent dialog, no VPN permission. Polling on a timer is the alternative people fall back to, and it burns battery while still missing a transition shorter than the interval, which is exactly the transition a user reports.

What the answer still cannot tell you

None of these APIs report whether the tunnel is healthy, which server it terminates on, or whether a particular app's traffic is inside it. A VPN network can exist with no usable routes, and a per-app tunnel can exclude the app that is asking the question.

So record what the check saw, not only the boolean. Log the network identifier, the declared transports and whether that network was active. A report saying "no VPN detected" is unusable, while one saying a network declared TRANSPORT_VPN but getActiveNetwork() returned Wi-Fi points directly at routing or at an exclusion list, and that is a fix someone can act on.

Related articles