Two paths can put an IP interface in front of an Android app, and both end at the same kind of file descriptor with very different owners. One runs through VpnService and its builder, the other through a root shell and /dev/net/tun. Every read on that descriptor is an outbound packet and every write is an inbound one. What separates the paths is who holds the policy, who survives an OS upgrade, and how much of the routing table you get to touch.

Take the platform path unless you can name the specific thing it refuses to do. That refusal is normally TAP, capture outside your own app set, or a routing rule the framework never exposes.

On the platform path you declare intent instead of commands. You supply at least one address, the routes you want, DNS servers and an optional app list, then Android's system service performs the privileged setup under its own identity. You never learn the interface name in advance, and you never issue the ioctl that creates it, which is the point.

Two ways to get an IP interface

The root path is the older recipe. You open /dev/net/tun, call TUNSETIFF with IFF_TUN, bring the interface up with ip link, and add addresses and routes by hand. Every step is yours to get right, including the ones the framework would have handled silently. One missing route produces the same user report as a dead server.

Debugging differs just as much as setup. On the root path the interface either exists or your ioctl returned an error you can read directly. On the platform path the interface exists only after establish() succeeds, so a failure arrives as an exception or a null descriptor rather than a device node you can poke at.

What establish() actually returns

Builder.establish() has three outcomes and they need three different responses. A descriptor means the interface is configured and yours to use, null means the request did not go through, and a thrown exception means the system service rejected it. Collapsing null and success into one boolean is the most common way an app reports a working tunnel that never carries a packet. The Builder reference states the address requirement, which is worth reading before you blame the server.

Directions are fixed by the descriptor, not by your protocol. Reads give you outbound packets already selected for the VPN, and writes inject inbound packets back into the interface. Your own transport encrypts, authenticates and frames those packets, because the platform stops at the packet boundary.

Binder is one hop, JNI is another axis

A builder request crosses into the system through Binder, and somewhere below that the platform calls native code to configure the device. Those are two different mechanisms stacked inside one call. Binder is interprocess communication between your app and a system service, while JNI is the Java-to-native bridge inside a process. Reading an Android 9 AOSP tree makes the stack look literal, and it is worth remembering that internal class names and file paths move between releases.

What does not move is the contract you may rely on: the builder, the descriptor it returns, and the service lifecycle around it. No app should call an internal service transaction or recreate a device node to obtain an interface. If your code depends on an AOSP class name, it will break on a release you did not test.

TUN carries IP, TAP carries Ethernet

TUN is an IP interface. TAP is an Ethernet interface, and that difference decides whether bridging is even possible. A TUN device has no MAC address and no Ethernet header to forward, so a TUN client cannot join a layer-2 bridge however the routes are written.

VpnService gives you the IP variant, which is what an app-level VPN needs and what it can support without a raw Ethernet stack. If your design requires bridging, the platform path will not get you there, and the root path only will if the kernel and the device permissions cooperate.

Where the root path still wins

Three jobs push people off the platform path. Layer-2 bridging needs TAP. Capturing traffic from apps outside your own VPN scope needs visibility the framework does not grant. Policy routing the builder never exposes, such as a separate table for one destination, needs rules you install yourself.

The costs arrive later: an unlocked bootloader or a rooted image, SELinux policy that shifts under you, and a device that stops behaving after an OTA. A rooted recipe also cannot ship through normal app distribution, so most teams keep it as an internal diagnostic path rather than a product.

Choosing between them

Start from the interface you need, not from the code you have. If the answer is a TUN device with app-scoped routes, the platform path is shorter and survives upgrades. If the answer involves Ethernet frames, capture of other apps, or routing rules the builder rejects, you are in root territory and should budget for it as an operating cost.

A check that settles most arguments: build the platform path first, record the establish() outcome separately from the remote session state, and confirm one packet in each direction. Only then decide whether the missing capability justifies leaving the supported path.

متعلقہ مضامین