Your app calls VpnService.prepare, gets a null back, builds a tunnel, and the descriptor looks valid, yet nothing reaches the server. Or prepare returns an Intent, the user approves it, and establish still fails. Both outcomes usually mean the same thing: another app already owns the VPN interface for this user, and your protect calls are marking sockets your tunnel cannot use. Work through the checks below in order and stop at the first one that fails, because a later step cannot repair an earlier one.

Check the VPN slot before you touch protect

Android gives each user exactly one active VPN interface. That is a platform rule, not a permission your app can request, so two clients that both want to be the tunnel cannot coexist.

An always-on VPN set for another app is the hardest case, because the system keeps that app connected and rejects a takeover. A traffic filter such as AdGuard, a corporate client, or a consumer VPN can hold the slot while the user has forgotten it is installed. Ask the user to open Settings and read the entry listed under VPN; the name there is the owner you are competing with. Until that entry is disconnected or the always-on setting is cleared, your VpnService cannot become the interface.

Step 1: read what prepare returns

VpnService.prepare answers a single question: may this app become the VPN? It returns null when consent already exists, and an Intent when the user still has to approve it, which you must launch with startActivityForResult. Treat a null return as permission to continue, not as proof that establish will succeed.

Intent intent = VpnService.prepare(context);
if (intent != null) {
    startActivityForResult(intent, REQUEST_VPN);
}

If the Intent path returns and the user declined, prepare will keep returning an Intent on the next attempt. Log the result code instead of retrying in a loop, because a declined consent is a user decision, not a transient error.

Step 2: confirm the interface belongs to your app

After establish, the ParcelFileDescriptor you receive is your proof of ownership, and it is worth checking before any socket work. A descriptor that is null or already closed means the interface was never created, even if no exception surfaced. Compare the interface name from the builder with the one the system reports for the active VPN, using dumpsys on a test device. If they differ, the tunnel you built is not the tunnel that is carrying traffic.

adb shell dumpsys connectivity

The same mismatch explains a connected icon with dead traffic. The system shows the other app's tunnel, while your code holds a descriptor for an interface it does not own.

Step 3: call protect before connect

Once the interface exists, sockets your app opens are routed into the tunnel by default. The outer transport cannot follow that rule or the encapsulated packet would loop back into the interface, so VpnService protect marks a socket to bypass the VPN and use the underlying network. The call returns a boolean, and ignoring it is how you end up with a socket that looks healthy and carries nothing.

Call protect before connect or bind, never after. Protection has to be in place before the socket is connected or bound, because a connected socket has already picked its route and a later protect does not reliably move it. If protect returns false, stop and re-check ownership: it fails when the interface does not belong to your app, which is exactly the state another VPN leaves behind. A false return is a signal to go back to Step 1, not to retry the same socket.

DatagramSocket socket = new DatagramSocket();
if (!vpnService.protect(socket)) {
    return;
}
socket.connect(serverAddress, serverPort);

Step 4: bind a process when a socket is awkward

Some transports are not a single socket: a library may open several, or you may want every connection in a helper process to use the same exit. ConnectivityManager.bindProcessToNetwork binds the process to a chosen network, and the older setProcessDefaultNetwork did the same job before it was deprecated. Request the underlying connection with NET_CAPABILITY_NOT_VPN so the binding selects the network under your tunnel rather than the tunnel itself.

NetworkRequest request = new NetworkRequest.Builder()
    .addCapability(NetworkCapabilities.NET_CAPABILITY_INTERNET)
    .addCapability(NetworkCapabilities.NET_CAPABILITY_NOT_VPN)
    .build();

Binding a process is not a second VPN. It chooses an exit for that process, which is useful for your transport and useless for running two tunnels at once.

Fix the user's device, not the code path

There is no API that lets two apps share the VPN slot, so the fix belongs to the device state. Offer the user two concrete paths: disconnect or clear always-on for the other app so your prepare can take over, or open that app's per-app settings and exclude your app from its tunnel. The exclusion keeps the filter running for everything else and lets your app use the ordinary connection, which is often the outcome the user actually wants.

Write both paths into your support documentation, because users rarely know which app owns the VPN. Ask them to send the active VPN name from Settings and the results of prepare, establish, and protect from your logs, and you can tell within a minute which step failed. Then verify with real traffic: a name lookup, a TCP connection, and one request through the tunnel, not just a connected icon. A valid descriptor and a green indicator prove ownership at best, never that the transport survived the routing rules.

If all four checks pass and traffic still dies, ownership is no longer your problem. Move on to routing and server reachability, and test the same credentials from a device with no VPN app installed.

Amanqephe ahlobene