The value reached VpnService.Builder.addDnsServer as a string, and the builder refused it there, before anything was established. That rejection is local: no resolver has been contacted, no tunnel exists, and what failed is a format check rather than a DNS outage. Reading the trace as a power-management conflict or a server-side problem sends you to the wrong layer. The frame names the argument that was refused, not the setting that produced it.

That matters because a typical VPN app has three DNS inputs that look alike: a value typed or imported into the profile, a value the server pushes during configuration, and Private DNS configured at system level. Only the first two can reach this call, and only one of them is usually where the bad string comes from.

A staging object checks the argument on the spot

VpnService.Builder is not the tunnel. It is a staging object that collects addresses, routes and servers, and only establish() turns those lists into a live ParcelFileDescriptor and an interface. The DNS entry is checked when you call addDnsServer, not when you establish, which is why the exception is thrown several lines above the code you were actually debugging.

Two consequences follow. The stack trace is short and unhelpful on purpose: the value arrived as a plain string, and the framework cannot know whether it came from a QR import, a server payload or a settings override. And because everything else in the builder survives the failed call, a careless catch around the whole build leaves you with a tunnel that has addresses and routes but no resolver at all.

The builder also keeps whatever list you give it. Adding a server that passes the check proves only that the string parsed as an address literal at build time, and the wildcard case below shows it does not even prove the value is usable.

What the argument is allowed to be

The parameter is an address, not an endpoint. Android's Builder reference takes an IPv4 or IPv6 literal, and the parse behind it is numeric-only: android.net.InetAddresses.parseNumericAddress is the public face of that parser from API 29, and the framework's own call lands on the same routine. A hostname is not a slow address here; it is an unparsable string, and that parse failure is what surfaces as a bad-address exception.

The practical mapping looks like this:

InputResultReason
10.8.0.1acceptedIPv4 literal
2606:4700:4700::1111acceptedIPv6 literal
dns.example.comrejectedhostname; numeric parsing does not resolve it
10.8.0.1:53rejectedport belongs to your own config model
https://dns.example.com/dns-queryrejectedDoH URL, a different configuration path
127.0.0.1rejectedloopback is not a remote resolver
0.0.0.0 or ::accepted by the builderparses fine, but names no destination; only your own validator rejects it

Version matters for how you validate, not for what the builder accepts. InetAddresses is available from API 29, so on Android 10 and later the literal parse is one call. The overload matters too: addDnsServer(InetAddress) is the original form, while the String overload is the newer addition, so a pasted string sample will not compile against an older SDK. Below API 29 the obvious substitute, InetAddress.getByName, will happily resolve a hostname with a blocking network lookup, which turns a typo into a slow startup instead of a clear error. If you still ship to those versions, run an explicit literal test before you touch the older API.

Loopback is refused here, a wildcard is not

Desktop VPN clients often point their own stub resolver at 127.0.0.1:53 and let the tunnel client forward queries. Copying that arrangement into an Android profile imports a value that describes the local machine, and there is no local machine to hand it to here: the tunnel's resolver has to be reachable on the other side of the interface. This is the one case the builder itself refuses: a null or loopback address throws IllegalArgumentException, as the reference contract states.

A wildcard is the opposite case, and it is where most readings of this exception go wrong. 0.0.0.0 and :: are not loopback, so the builder accepts them and appends them like any other literal; nothing throws at build time. The interface then comes up with a resolver entry that names no destination, and the failure moves from an exception to queries that quietly go nowhere. Catching that is your validator's job, not the framework's: treat isAnyLocalAddress as invalid input in the helper below and surface it as a field-level error.

Parse once at the configuration boundary

The cheap fix is to validate where the string enters your model and pass the parsed object onward, so the value the builder sees is the exact value you validated:

import android.net.InetAddresses
import java.net.InetAddress

private fun dnsLiteralOrNull(raw: String?): InetAddress? {
    val text = raw?.trim().orEmpty()
    if (text.isEmpty()) return null
    val address = try {
        InetAddresses.parseNumericAddress(text)
    } catch (e: IllegalArgumentException) {
        return null
    }
    return address.takeUnless { it.isAnyLocalAddress || it.isLoopbackAddress }
}

A null result then becomes a field-level error carrying the offending text, which is the only form a support conversation can act on. On the success path the returned object goes straight into builder.addDnsServer(address), so the value you validated is the value that ships. Catch IllegalArgumentException specifically rather than Exception: a broad catch over configuration code also swallows the null-pointer and state bugs you want to see in a debug build.

Resist the fallback of substituting a public resolver when the intended value is rejected. A profile that silently swaps in a public DNS service still connects, but internal names stop resolving and every query leaves through a resolver the user did not choose. A visible error costs one support reply; the silent substitution costs the trust that the tunnel keeps queries where the profile says.

Confirm reachability after the interface exists

Acceptance by the builder is the first checkpoint, not the last. Once the interface is up, LinkProperties.getDnsServers() reports what the network actually received, and that is where a mismatch shows up: an empty list, fewer entries than you added, or a family your routes do not carry. adb shell dumpsys connectivity prints the same per-network properties when you need to look from outside the app.

Private DNS is a separate control and does not travel through this argument. A DoT provider hostname belongs to the system setting, or to your own encrypted resolver path; it is not a value to paste into an IP field, and fixing a Private DNS setting will not clear an exception thrown by the builder.

Before you change the profile parser again, work this order: log the raw string and its source, confirm it is a literal rather than a hostname or URL, confirm it is neither loopback nor a wildcard, pass the parsed object instead of re-parsing the text, and only then compare the established interface against the value you intended.

ХӀокхунах тера йаззамаш