Four assumptions about Android VpnService cost me most of a week once, and none of them was about the wire protocol. The app said connected, the key icon sat in the status bar, and traffic still went out over the normal route. I kept assuming the tunnel was mine until I stopped it, that the routes I added were the routes I got, and that calling stopSelf would take the interface down with it. The service was behaving exactly as documented. My mental model was the broken part.

Myth 1: the tunnel stays up until you stop it

Android holds one VPN interface at a time. Establish a second one and the platform does not queue it, it deactivates yours and calls onRevoke() on your service. If your code only tears down on a button press, a takeover leaves a descriptor and a reader thread behind, and the next start inherits both.

The VpnService reference is explicit that prepare() returns null when the app already has consent and an Intent when the user still has to approve. That approval only lands if you deliver the intent through startActivityForResult(). Skip the handoff and establish() returns null, which reads as a silent failure rather than a permission problem.

Always-on VPN adds an owner you do not control. The system can bring the tunnel back after boot or after a crash, and it pre-empts a VPN app the user started by hand. Build onRevoke() so it closes the same resources as your normal shutdown, and treat it as a primary stop path instead of a corner case. Users switch VPN apps; your code has to survive that.

Myth 2: addRoute captures exactly what you listed

Routes decide which packets reach your descriptor, and a scope error produces the maddening case where some traffic is tunnelled and some is not. addRoute("0.0.0.0", 0) takes all IPv4, which means every packet now arrives at you and has to be forwarded or dropped. A /32 for a single host takes almost nothing, and everything else behaves as if your VPN were never installed.

Families are counted separately. An IPv4-only route leaves IPv6 on the underlying network, so a dual-stack host can still resolve and connect outside the tunnel while looking perfectly connected inside it.

DNS follows the same logic. Servers added with addDnsServer() go to apps that use the tunnel resolver, while an app with its own encrypted DNS endpoint or Private DNS set to a hostname ignores them entirely. When names fail for one app and work for another, check the resolver path before you touch a single route.

Myth 3: per-app rules are a switch you can flip either way

addDisallowedApplication() and addAllowedApplication() are two modes, not two filters. The builder accepts one model per tunnel, so an app that wants everything except three packages uses the disallowed list and leaves the allowed list empty. The two are mutually exclusive, and people find out at runtime after the settings screen already offered both toggles.

Package visibility bites as well. On Android 11 and later you can only name packages you can see, so an exclusion list built from a query your app is not allowed to run will quietly miss entries. Compare the list against what the package manager actually returns before trusting it.

Work profiles split the audience again. A tunnel started inside the profile covers that profile and its apps, while the personal side keeps its own connectivity. That is a scope decision made once, and it belongs in the UI rather than in a support ticket.

Myth 4: stopSelf ends the tunnel

It does not. The interface lives in the descriptor returned by establish(), and the service object can die while that descriptor stays open. Close the ParcelFileDescriptor first, drop the reference, join or interrupt the reader and writer threads, then call stopForeground(true) and stopSelf() in that order.

A forgotten descriptor is the usual reason a service appears to survive its own stop request, and it explains a stale route that outlives the app that added it.

The reverse mistake is just as common: closing the descriptor while a reader thread is mid-read, then leaving that thread to handle an exception nobody wrote for it. Keep the shutdown sequence in one place and have both the user action and onRevoke() call into it.

What to check when the tunnel looks dead

Protect long-lived sockets with protect() before the tunnel comes up. Without it, the tunnel's own transport gets routed back into the tunnel and the connection stalls in a way that looks exactly like a server fault. Test against an address that exists only behind the remote network, not a public site that answers from cache or from the underlying path.

Log the result of establish(), the routes, the excluded packages and the shutdown reason. Those four lines separate a consent problem from a scope problem from a lifecycle problem without a packet capture.

Nuŋlɔɖi siwo ku ɖe eŋu