The tunnel comes up, the key icon appears, and every request in the browser still times out. In a VpnService app that combination usually means the read loop is alive and the output half is broken, not that the server rejected the handshake. Android already created the interface, moved the app traffic into it, and handed your process a file descriptor. Everything after that descriptor is your code, and one missing call is enough to swallow the whole stream.

What the system hands you, and what it does not

establish() returns a ParcelFileDescriptor for a tun interface. The system writes outbound IP packets into it and expects your process to read them, decide where they go, and send them through an ordinary socket. There is no kernel forwarding underneath, so when nobody reads, the packets are dropped and the interface counters never move.

Start by counting instead of guessing. Increment a counter on every successful read and log the destination and protocol of the first few packets. If the counter stays at zero while traffic is being generated, the fault sits upstream of your forwarding code, which narrows the search to the builder configuration and the read mode. If the counter moves and the browser still fails, the fault is in the send path or on the way back.

Blocking reads decide whether the loop moves

setBlocking(true) puts the descriptor into blocking mode and has existed since API 21. On older releases the workaround is a small JNI helper that clears O_NONBLOCK with fcntl on the raw descriptor. The mode matters because it decides what a read with no pending packet is allowed to do.

In non-blocking mode that read returns immediately with a negative value, and a loop that ignores the result spins a core while moving no traffic at all. In blocking mode the read waits, which is what a forwarding thread wants, but the thread can then no longer be interrupted. Closing the descriptor is the supported way to make the read fail so the loop can exit. Killing the process or interrupting the thread instead can leave the next start holding a descriptor that no longer matches the interface.

Read one packet per call into a buffer sized for the interface MTU, and treat zero or a negative return as an error rather than an empty packet.

protect() before the first send, not after it

Every socket that carries an encapsulated packet has to be protected first. An unprotected socket obeys the same routing rules you just installed, so its packets go back into the tunnel and your own read loop picks them up again. The tunnel then forwards its own output indefinitely, and from the outside it looks like a connection that opens and never answers.

The order is protect, then connect or bind, then send. Calling protect after connect can leave the earlier route decision in place, so the socket keeps using the wrong path even though the call returned true. Check the boolean it returns and fail loudly when it is false instead of walking into a send that cannot work.

A permission error from sendto on a socket that never touched protect() is the usual signature of this mistake.

The UDP sample does not carry HTTPS

The forwarding sample that most tutorials copy handles UDP and nothing else. TCP needs a handshake, sequence numbers, acknowledgements and retransmission, and a loop that copies payloads between a tun descriptor and a socket provides none of those. That is why a UDP DNS query can pass while every HTTPS request stalls. Either run a userspace TCP stack or hand connections to a local proxy that terminates TCP on the device and forwards the stream.

Test the split before rewriting anything: send one UDP query and open one TCP connection to the same host. If the UDP path works and the TCP path hangs at the first SYN, the forwarding code is the problem and the server is not. That single comparison saves a lot of time spent reading server logs that contain nothing useful.

Route list and per-app filters bound what you see

addAddress, addRoute and addDns define what the system sends into the tunnel. A destination outside the route list never reaches your descriptor, so no amount of forwarding logic will fix it. Log the builder calls you make and compare them against the addresses the app actually tries to reach.

Per-app selection has its own floor: addAllowedApplication and addDisallowedApplication need API 21, and on older releases the methods are simply absent. A design that filters by package cannot run there at all.

Re-establish leaves stale descriptors behind

Calling establish() again returns a new descriptor and closes the previous interface. The old descriptor is now invalid, so a thread still blocked on it gets an error, and sockets created for the old path may need to be replaced. onRevoke is the callback for a tunnel the user or the system has taken away. Keeping the lifecycle in one owner, which stops the reader before establishing again, prevents most of the connected-but-dead states.

Read each error by the step it belongs to. A connection reset from recvfrom means the remote end closed while your code was still reading. A bad address error points at header parsing or an offset bug, not at the network.

Order of work: blocking mode on, one packet per read, header parsed, socket protected, payload sent, clean close on handover, and a route list that covers the destination.

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