Android's VpnService has no exclude route, and no amount of API reading will produce one. Builder.addRoute() only ever adds destinations to the tunnel, so keeping a LAN segment local means that segment never enters the route list at all. What you add instead is the complement of the segment, expressed as ordinary prefixes. The work is arithmetic, and it fails in a handful of predictable places.

Work down the checks below in order. Each one assumes the previous held, so stop at the first failure instead of patching symptoms further down.

Start from what the tunnel claims

Passing 0.0.0.0/0 to addRoute() tells Android that every IPv4 destination belongs to the tunnel. That is why so many clients look incapable of split tunneling: they claim the full address space and then try to subtract from it. Nothing in the builder subtracts. A more specific route for the network you wanted to keep local does the opposite of what people expect, because the longest matching prefix wins and that network lands inside the tunnel.

The route list is the entire policy. There is no per-destination veto behind it, and the Builder reference lists no method that would provide one.

Generate the complement from prefix arithmetic

The complement of an excluded prefix is the set of its siblings on the path to the root of the address tree. For each length from 1 up to the excluded length, take the block of that length containing your prefix, flip the bit immediately below the prefix, and emit the result. It sounds abstract until you run it.

Excluding 10.0.0.0/8 yields eight routes: 128.0.0.0/1, 64.0.0.0/2, 32.0.0.0/3, 16.0.0.0/4, 0.0.0.0/5, 12.0.0.0/6, 8.0.0.0/7, 11.0.0.0/8. None of them contains 10.0.0.0/8, and together they cover everything else.

Eight lines generate that list for any prefix:

import ipaddress
def complement(addr, length):
    base = int(ipaddress.IPv4Address(addr))
    out = []
    for k in range(1, length + 1):
        block = base & (0xFFFFFFFF << (32 - k))
        sibling = block ^ (1 << (32 - k))
        out.append(f"{ipaddress.IPv4Address(sibling)}/{k}")
    return out
print(complement("10.0.0.0", 8))

Route count equals prefix length, and that is the number to watch. A single /21 exclusion costs 21 routes and stays readable. Ten exclusions can cost a couple of hundred, which usually means the network design needs changing rather than the generator.

Check the address family and the tunnel scope

IPv4 routes do nothing for IPv6. If the tunnel claims an IPv6 address, an IPv6 LAN prefix needs its own complement, generated with the same arithmetic over 128 bits. A common cause of the exclusion working on paper while the local device stays unreachable is this: the host was reached over IPv6, and only IPv4 had been carved out. Check both families before you conclude the maths is wrong.

Scope is the other half of the check. Android tracks routing per network and per app, so the same prefix can be tunneled for one app and direct for another depending on how the VPN was declared.

Put DNS and per-app rules where they belong

Route exclusions do not change the resolver. A local hostname can still be answered by the tunnel's DNS server while its address is routed locally, and the mismatch shows up as a name resolving to something the LAN cannot reach. Set resolver policy deliberately, with a search domain or a split-DNS rule. Do not assume the routes decided it for you.

addDisallowedApplication() keeps one app out of the tunnel; it does not keep a destination out for the apps that stay in. protect() lets a socket you open bypass the tunnel, which is how a tunnel avoids capturing its own transport. Neither one replaces the complement.

Verify with a lookup, not with your own list

Once the tunnel is up, ask the device where an address actually goes instead of re-reading the list you built. On a build that ships iproute2, adb shell ip route get 10.1.2.3 names the interface the kernel selected. Run the same command for a public address and confirm it names the tunnel.

Two more checks close the loop. Resolve a local name first, then run adb shell ip route get on the resolved address and require an interface that is not the tunnel, because a successful adb shell ping -c 1 host.lan proves reachability and not the path. Repeat the address-family test over IPv6 if the tunnel claims one. Test on the API level you ship, because how VPN networks are exposed to the routing table has changed across releases. A route list that looks right on paper and a lookup that picks the wrong interface are different bugs, and only the second one reaches users.

If the exclusion list keeps growing, stop generating routes and renumber the remote network or narrow the tunnel instead. The generator is a workaround for the missing API, not a design tool.

Winndannde jowitiiɗe heen