You have two ways to keep the tunnel's own packets out of the tunnel: protect the transport socket, or take the whole app out of the VPN. They solve different problems, and picking the wider one because it is easier to wire up is how apps lose local-network access and split DNS without noticing. Start from what has to stay outside the tunnel, then choose the smallest scope that achieves it.

What protect() removes from the tunnel

VpnService.protect() is a per-socket call. You hand it the socket or descriptor that carries the tunnel, and Android marks that socket so its traffic uses the underlying network instead of the VPN interface. Everything else the app sends still follows the normal VPN route.

That narrow scope is the point. A protected socket does not change how your HTTP client resolves names, which interface your analytics calls use, or what happens to a second connection opened later.

Three conditions that decide the scope

Condition one is whether only the tunnel transport has to escape. If the app's other traffic should still go through the VPN, socket protection is the correct scope. Condition two is whether you control socket creation. A library that opens its own socket deep inside gives you no place to call protect() unless it exposes a hook or accepts a pre-built socket. Condition three is whether the app must reach the local network or a captive portal directly, which is a different requirement from keeping the tunnel alive.

Excluding the app with addDisallowedApplication() is the blunt tool. It removes every connection the app makes from the VPN, including the ones you wanted inspected, and on some devices it also changes how local discovery behaves.

A third option is to bind the transport to a chosen network with bindProcessToNetwork() or a socket-level network handle. That is useful when the device has Wi-Fi and cellular at once and you want the tunnel to ride a specific uplink.

Call it while the transport is being created

Call protect() in the same block that creates the transport, before connect() or send(). If the call returns false, close the socket and fail the setup; continuing leaves you with a transport whose packets may re-enter your own interface.

The shape is small enough to keep in one place:

DatagramSocket transport = new DatagramSocket();
if (!protect(transport)) {
    transport.close();
    throw new IOException("transport not protected");
}
transport.connect(serverAddress);

Three details bite here. First, the boolean is the only signal you get, so log the failure with enough context to tell a revoked consent from a refused call. Second, protect() covers the socket, not the descriptor number: if you close the socket and later reuse the same integer for another socket, the new socket is not protected. Third, when a native library opens the transport, protect the descriptor before the native code connects, or the route may already be chosen. The VpnService reference is the place to confirm the overloads your target API level actually provides.

Test the blocked path, not just the happy path

Verify with the tunnel up, because that is the only state in which the two paths can differ. Send one request through the protected transport and one from a code path that never called protect(), then compare what the far end sees: the protected connection should arrive with the underlying network as its source, while the unprotected one shows the tunnel's own address and moves the tunnel's byte counters. On the device, ss or netstat shows the same split before anything leaves the radio.

Test revocation separately, because a consent change tears down the interface without touching your socket code. Revoke the VPN consent or stop the service, rebuild the transport, and confirm that protect() still returns true and that the transport reconnects once consent is granted again.

If the protected request follows the tunnel anyway, the protection call is probably happening after the socket has already been routed. Move it earlier and test again.

Keep the decision written down next to the transport setup: which sockets are protected, which app traffic is excluded, and what the app does when protection fails. That line is what stops a later refactor from widening the exclusion to the whole app because a single test was failing. It also gives the next person a way to check the intent without reverse-engineering the manifest.

Winndannde jowitiiɗe heen