Nothing about a VpnService intercepts traffic on its own. It asks the system for a tun interface, hands that interface a set of addresses and routes, and from that moment the kernel copies every packet matching those routes into a file descriptor the app can read. The app's code never sees the packets the routes did not select, so the scope of a tunnel is a routing-table question before it is a programming question. That is why the same service implementation can swallow every flow on one phone and only a slice on another, and why adding a route sometimes repairs a tunnel that looked broken. What you get is what you declared, minus whatever the builder quietly refused.

Routes decide which packets arrive

Builder.addRoute(prefix, prefixLength) installs a prefix into the VPN network's routing table. The system treats that network as the default for the apps in scope, so its entries are consulted before the physical network's default route and a matching prefix is enough to divert a flow. A route for 0.0.0.0/0 therefore takes IPv4 in full, including the printer, the cast target and the router's admin page.

Clients that want that coverage without deleting the physical default entry use the pair 0.0.0.0/1 and 128.0.0.0/1 instead. Two more-specific routes beat a default route, together they span the whole address space, and the phone's own default entry stays in the table for the sockets that are still bound to the physical network.

Addresses and DNS entries come from the same builder and answer a different question. addAddress gives the interface an address with a prefix length, and addDnsServer installs a resolver that in-scope apps will query through the interface. Both families can sit on one interface, so an IPv4-only route set next to an IPv6 address is a configuration that reads correctly and still leaks. If you route a family the interface has no address in, the kernel has no source address for those packets and they never appear at the descriptor.

What establish() hands back

establish() returns a ParcelFileDescriptor, or null when the app is not authorized. Null is a consent state rather than a network error: VpnService.prepare() must have returned null already, or the user must have approved the system dialog before the call. Consent can be revoked while the service runs, and the interface closes under you with no callback into your read loop.

The descriptor is non-blocking unless you say otherwise, so a read returns immediately with no data instead of parking the thread. A loop written for a blocking socket burns a core, and a loop that treats an empty read as fatal tears the tunnel down during an idle second. Builder.setBlocking(true) changes that behaviour, and the price is a thread that waits inside the read.

Route and address arguments are validated as LinkAddress values, and multicast destinations such as 224.0.0.0/4 are rejected outright. A point-to-point tun interface has no broadcast domain either, so multicast discovery over it needs a different design rather than one more route. The VpnService.Builder reference is the list to check before you conclude that a call was ignored.

Per-app scope narrows the set, not the tunnel

From API 21 the builder accepts addAllowedApplication and addDisallowedApplication, and the two lists are mutually exclusive within one builder. An allow list makes the tunnel exist for those packages while everything else keeps the physical network; a disallow list captures everything except the named packages, which is what a client offers as split tunneling. Before API 21 there is no platform mechanism at all, and per-app behaviour came from third-party clients filtering by UID inside their own forwarder. The distinction matters when you debug, because a per-app tunnel with an allow list of one package produces almost no traffic and that is correct behaviour, not a broken route.

Whatever the scope, the socket your app opens to the VPN server must not travel through the tunnel it is building. VpnService.protect(socket) binds that socket to the underlying network so its packets skip the tun device. Skip the call and the handshake is swallowed by the interface the handshake is supposed to bring up.

VpnService.setUnderlyingNetworks closes the loop by telling the system which physical network carries the tunnel. That value drives how the VPN reports connectivity to other apps, and a null or stale value is a routine cause of a connected tunnel that the rest of the phone treats as offline. Update it when the underlying network changes, not only at startup.

Checksums and MTU: where a correct route still fails

Once packets do arrive, two details at the packet level decide whether anything works end to end. The first is the source address: with several interface addresses the kernel may emit packets carrying the first one, so a forwarder that rewrites headers has to fix the address and the checksum that covers it.

The second is checksum scope. When the app builds TCP or UDP packets itself, the checksum covers a pseudo-header holding the source address, the destination address, the protocol number and the transport length, and leaving out any of those yields packets that arrive and are dropped. That failure looks like a timeout after a reply that never comes, which sends most people back to the routes. setMtu fixes the interface MTU, and each read returns exactly one complete IP packet, so a buffer written for a byte stream will fragment your parsing rather than the packet.

A verification pass that separates the layers

To find where the scope went wrong, test the layers in order. Ask the device which routes the VPN network actually holds, then check whether a flow to a known address shows up as a read on the descriptor, then check whether the packet your forwarder emits is well formed. A route problem shows nothing at the descriptor, while a checksum problem shows a packet that arrives and vanishes.

If nothing reaches the descriptor, the route set is the suspect rather than the reader: confirm the prefixes, confirm there is an address in the same family, and confirm the app is inside the scope you set. If packets reach the descriptor but nothing comes back, inspect protect() and the underlying network value before you touch a route again. If the far side reports malformed packets, the checksum and the MTU are where to look. One probe per layer saves an afternoon of guessing.

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