Split-tunneling on Android: three approaches, two failures
From VpnService to VpnService extensions to userspace packet filtering — what actually works in 2026.
What we want
User: "I want my bank direct, Netflix via VPN." Sounds trivial — it has worked on desktops for years. On Android, it's hard.
Attempt #1: VpnService.Builder.addAllowedApplication()
Android's API has addAllowedApplication(packageName) and the inverse addDisallowedApplication. Looks like the solution.
Problem: for multiple apps the API is whitelist-only or blacklist-only, not both at once. And users can post-hoc edit VPN-exclusion lists only on Android 10+.
Works, but the limitations are awkward for users. Failure #1.
Attempt #2: extended VpnService with two interfaces
We tried to bring up two virtual interfaces — one for VPN traffic, one for direct — and route between them via our own packet filter.
Problem: Android doesn't allow two simultaneous VpnServices. One app = one tunnel. Failure #2.
Attempt #3: VpnService + userspace iptables-style packet filter
The only approach that works. We intercept packets from VpnService, inspect the source process UID, and — per rules (app-based or domain-based) — decide whether to send through VPN or NAT into the local network.
Gotchas:
- Performance: a userspace packet filter in Java/Kotlin is slow. We moved it to native (Rust via JNI).
- Domains via DNS — we also intercept, build a DNS cache, and route off that.
- Battery: the filter runs constantly. We tuned a fast path for uncacheable categories.
Net: ~3–5% battery overhead and 0.5 ms latency. Invisible to users.
Code
We open-sourced the packet filter as a standalone library: github.com/logrus/android-pktfilter. Apache 2.0. Used by our client and outside contributors.