You cannot ask Android whether a VPN is connected. VpnService exposes no connected getter, and the key icon in the status bar is drawn by the system, not by anything your app can read. What you can do is ask the connectivity stack a narrower question: which network is this app's traffic using, and does that network carry the VPN transport. This runbook walks the checks in the order that avoids false positives, from a shell command on the device up to the callback you leave running in the app.

Check the device from a shell before touching app code

Get a baseline first. adb shell dumpsys connectivity prints every network the system knows about, and each block lists its transports, so a VPN network appears with VPN in that list. Read the default network block before anything else, then look for other networks that carry the same transport.

Two outputs answer different questions. Whether a VPN network exists is not the same as whether your app is using it, and on a device with a work profile or a second user, both can be true at once while your process talks to the internet directly. The active network line is the one that decides what your sockets do.

Ask for the network the app is using

In code the capability test is four lines. Request ACCESS_NETWORK_STATE in the manifest, then compare the active network's capabilities against the VPN transport:

ConnectivityManager cm = (ConnectivityManager)
    context.getSystemService(Context.CONNECTIVITY_SERVICE);
Network active = cm.getActiveNetwork();
NetworkCapabilities caps = cm.getNetworkCapabilities(active);
boolean vpnInUse = caps != null
    && caps.hasTransport(NetworkCapabilities.TRANSPORT_VPN);

The answer is deliberately narrow: is the app default network a VPN right now. It says nothing about which VPN, whether it belongs to you, or whether the tunnel reaches its far end. Treat a true as "traffic leaves through some tunnel" and keep your own bookkeeping separate. A boolean that silently means three things at once is how a settings screen ends up reporting the VPN as off while the user is browsing through it.

When you need to know whether any VPN network is present instead, iterate cm.getAllNetworks() and test each capability set. The two answers diverge on a device running a per-app VPN that covers other applications but not yours, and that divergence is the source of most bug reports that say the app cannot see a VPN that is clearly on.

Watch for changes instead of polling

Polling on a timer is the wrong shape for this question, because state changes arrive as events. Register a request that matches the VPN transport and let the framework tell you when a matching network appears, disappears, or changes:

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

A VPN network never carries NET_CAPABILITY_NOT_VPN, so the explicit removal documents the intent. Without it the request still matches, but the next person reading the code has to work that out.

Registration belongs to the scope that needs the observation, usually a screen or a foreground service. Unregister in onStop or onDestroy, and do not leave a callback attached to an activity that is already gone. An app with no visible component is a poor host for a permanent observer, and the callbacks you forget are the ones that crash after a configuration change.

Keep three questions apart

Most of the confusion in this area comes from treating three different checks as one. They use different APIs, and each one can be true while the others are false.

QuestionHow you answer itWhat a yes means
Did the user grant VPN consent to my app?VpnService.prepare() returns nullConsent exists; the tunnel may still be down
Is the device sending default traffic through a VPN?TRANSPORT_VPN on the active networkSomething is tunneling, not necessarily your app
Did this request arrive through the VPN?Source address seen by the serverProof for that connection, nothing wider

The third row is the one people try to answer on the client, and it cannot be answered there. Give VPN clients their own address pool and read the source address on the server; that is the only evidence that survives a user switching tunnels mid-session.

Verification order that rules out false positives

Older sample code still in circulation calls getNetworkInfo(ConnectivityManager.TYPE_VPN). That call is deprecated, and on several releases it returned null even while a VPN was connected, which is why so many copied snippets look broken on a device that works. Capability checks avoid the trap and behave the same from Android 5.0 onward. If you maintain a codebase that still calls it, replace the call instead of wrapping it in a fallback, because the fallback is what produced the wrong answer in the first place.

One shortcut deserves a warning. Comparing the public address reported by an external service with the VPN exit address does prove something about the path, but it adds a third-party request to every check, depends on that service staying up, and can be cached or blocked. Keep it as a manual diagnostic, never as the state check inside the app.

Run the checks in this order and stop at the first one that disagrees with the user's report: shell transport listing, active network capabilities, callback events while you toggle the tunnel, then server-side source address. If all four line up and the app still misbehaves, the bug is in your own state machine rather than in Android's view of the network.

Nsɛm a ɛfa ho