An Android app fails to resolve an internal name while the VPN client's own browser reaches it, and the same build works on another phone. The URL is not the variable. The variable is which network the app process is allowed to use for resolution, and Android does not guarantee that the active VPN is the one your sockets use. Work these checks in order and stop when the resolver snapshot disagrees with your assumption.

Step 1: Prove which network the app is on

Read the app's own view rather than the status bar. From ConnectivityManager, get the active network, its LinkProperties, the DNS server list and the private DNS setting. If the list contains the VPN's resolver, the app has a path to it; if it contains the Wi-Fi resolver, the tunnel is not the default network for that process.

A VPN icon only says a tunnel exists. It does not say the app's process was bound to it, and Android does not always route name resolution through the network that looks active. Local VPN apps that forward to another device, Wi-Fi networks without internet and vendor builds can all leave an app asking the default resolver instead of the tunnel's servers.

Step 2: Request the VPN transport and bind the process

When the app must use the tunnel's resolver, request a network with the VPN transport and bind the process in the callback, so the code does not guess when the network appears:

val request = NetworkRequest.Builder()
    .addTransportType(NetworkCapabilities.TRANSPORT_VPN)
    .build()

connectivityManager.requestNetwork(request, object : NetworkCallback() {
    override fun onAvailable(network: Network) {
        val bound = connectivityManager.bindProcessToNetwork(network)
        Log.i(TAG, "bound to VPN network: " + bound)
    }
})

The call changes the process-wide default network, so every socket created afterwards uses it. Sockets that already exist keep the network they were created on, which is why a connection pool opened before the bind can keep resolving through the old path. The return value is a boolean, and a false result means the bind did not happen; retry with a short delay and log both attempts.

Step 3: Scope the bind to what needs it

Process-wide binding is blunt. Telemetry, update checks and third-party SDKs also move to the tunnel, and if the tunnel has no internet they start failing. For a single request, use the network's socket factory and keep the device default for everything else. Pick one model deliberately: a narrow bind per connection, or a process bind with an explicit unbind when the VPN disappears.

Two resolver features can defeat the bind without any error. Private DNS in strict mode sends queries to a named DoT server instead of the network resolver, and an app that ships its own DNS-over-HTTPS client bypasses the system resolver entirely. Check both before concluding that the bind failed. A query that never reaches the VPN's DNS server will not appear in any tunnel-side log.

Step 4: Do not trust a CONNECTED constraint

WorkManager's NetworkType.CONNECTED means a network is connected, and the VPN transport satisfies it even when the underlying Wi-Fi or cellular link is gone. The job runs, every request fails, and the retry policy turns it into a storm that looks like a scheduling bug. The tunnel is a network; the constraint was never an internet check.

Check the capability the work actually needs, such as validated internet access, and keep a bounded retry policy with backoff. If the job needs a private name, resolve that name in the selected network before doing the work, and fail fast with a clear error instead of retrying a request that cannot succeed.

Step 5: Record the evidence and retest the range

Log the selected network's transport types, the DNS servers from LinkProperties, whether the bind returned true, and the first request's exact failure. Then run a private name and a public name under the same binding. That pair shows whether the app used the tunnel resolver or silently fell back to the device default, which is the difference the user experiences.

Repeat on the oldest Android version you support and on one current release. The resolver behaviour that makes the default path look correct is exactly where devices and versions differ, and a build that passes only on the newest test device has not been verified for the range you ship. adb shell dumpsys connectivity is a quick way to confirm which networks and capabilities the device reports while you reproduce the failure.

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