Split tunneling in a VpnService app breaks in a handful of specific places, and the repair differs for each one. The client only shows a destination that fails, so the first job is to work out which boundary you are hitting: the routes the builder received, the proxy you cannot authenticate, the resolver you only advertised, or the app list the platform applies per user. I keep a four-step check for exactly that reason. It runs in the order below, and it stops at the first failure.

Confirm what the builder handed the platform

VpnService.Builder is a declaration list. establish() returns a ParcelFileDescriptor for a virtual interface, and the platform sends only traffic matching the prefixes from addAddress and addRoute into that descriptor; addDnsServer tells clients which resolver to query. A destination outside every prefix you added never reaches your app, no matter what the UI promises. That single fact explains most reports of broken split tunneling before any code changes.

Log the address, route and DNS lists your builder assembled, then compare them against the destinations that fail. One missing /32 for a management host is far more common than a broken tunnel. If the list is right, move on; if it is wrong, the rest of the checklist is wasted effort.

excludeRoute exists only from API 33

Destination-based exclusion is the request that most often becomes a support thread. builder.excludeRoute(IpPrefix) arrived in API 33; before that, the only supported approximation is to add routes for everything except the excluded prefix, which means computing complementary prefixes yourself. The two calls are not mirror images: one subtracts from the interface route set, the other builds the set from scratch, and an older device will throw or silently ignore a call it does not recognise. Branch on Build.VERSION.SDK_INT and test both paths on real hardware.

When you compute complements, watch prefix alignment. A /24 exclusion inside a /16 route needs the covering prefixes that actually describe the remainder, not one malformed /8. Log the final list and assert that the excluded host still resolves through a non-tunnel path.

Proxy credentials are not expressible

setHttpProxy(ProxyInfo) points the interface at an HTTP or SOCKS proxy, and a PAC URL can choose between them per destination. Neither ProxyInfo nor the PAC format carries a username and password, so an authenticated proxy sits outside the platform API. Apps that need one usually embed a userspace stack, tun2socks or a similar engine, and answer the 407 challenge themselves.

That choice has consequences. A userspace stack sees only traffic you route to it, so it must live inside the same descriptor boundary, and it inherits the same protect() requirement for its own uplink socket. Skip protect() and the tunnel captures its own transport, which shows up as a handshake that stalls after the first packet. Test against a proxy that demands credentials, because an open proxy passes on the platform path and proves nothing.

Per-app lists and DNS are declarations too

Builder has both addAllowedApplication and addDisallowedApplication, and they are mutually exclusive: calling both throws. Either way the list is applied per user at interface setup, so a work-profile app is governed by the work profile tunnel rather than yours. What no API gives you is control over another app decision: you cannot force a package into your tunnel from outside, and you cannot close or reconfigure a VPN that is already active.

DNS follows the same pattern. addDnsServer advertises a resolver address; it does not let your app answer queries. If you want to resolve names yourself, run a resolver and route its address into the tunnel. Apps that use DoT or DoH to a fixed hostname can bypass the address you advertised, so treat name resolution as best effort unless you intercept the query path as well.

Manifest, always-on and the four-step test

Eligibility is declared in the manifest: BIND_VPN_SERVICE, the android.net.VpnService intent filter, and a foreground service if the tunnel must survive in the background. Always-on VPN additionally requires a clean onRevoke, because the system calls it when the user changes settings or another app takes over.

Run the checks in order and stop at the first failure. First, dump the address, route and DNS lists passed to the builder. Second, confirm establish() returned a descriptor and that the transport socket is protected. Third, request one destination inside an included prefix and one inside an excluded prefix. Fourth, check which resolver actually answered by querying a hostname only your resolver can resolve.

A completed handshake proves none of the later steps. Each check isolates one layer, so a pass at step two tells you nothing about step four.

The VpnService.Builder reference lists the full builder surface. Anything you need beyond it is another API, another app, or your own userspace stack, and that is a design decision rather than a split-tunnel bug.

Barruulee wal qabatan