NetValve is intentionally honest about what a non-root, local traffic controller can and cannot do on Android. Everything here is also reflected in code comments near the relevant logic.
Android allows a single active VpnService. Consequences and handling:
- Starting another VPN app revokes ours →
NetValveVpnService.onRevoke()stops the engine cleanly, persistsenabled=false, and surfaces a Revoked status. No crash, no zombie tunnel. - Starting NetValve while another VPN is active shows the system's
replace-VPN consent dialog (standard
VpnService.prepare). - After reboot, the tunnel re-arms only if it was enabled, auto-start is on, and
consent still stands (
BootReceiver).
addAllowedApplication/addDisallowedApplication are fixed at establish().
Editing the selection while running therefore needs a rebuild — NetValve does
a seamless ACTION_RESTART (stop engine → re-establish → restart). Editing a
rule's caps/conditions does not need a rebuild; it applies live via
RuleEngine.revision.
Non-root attribution uses ConnectivityManager.getConnectionOwnerUid (API 29+),
which is why minSdk is 29. It is racy and imperfect, so UidResolver layers:
- a short-TTL cache per 4-tuple;
- the kernel lookup, wrapped so exceptions never reach the packet loop;
- single-controlled-app inference (if exactly one app is controlled);
- an Unknown bucket (global default policy) otherwise.
The engine never crashes on attribution failure; failures are counted and rate-limited in the log. On some OEM ROMs a fraction of flows may be Unknown — they still flow and are shaped by the global default.
IPv6 is always routed into the tunnel (so it cannot bypass shaping), with two
modes (GlobalSettings.ipv6Mode):
- RELAY (default) — IPv6 TCP/UDP is terminated, attributed, and shaped exactly like IPv4 by the netstack engine.
- FAST_REJECT — IPv6 flows are refused immediately (TCP RST / ICMPv6 unreachable) so apps fall back to IPv4 within milliseconds. This exists so that on networks where upstream v6 misbehaves, users never see long connection timeouts. It is reject-fast, not silently-drop (which would cause the very timeouts we want to avoid).
Why not "just don't route v6"? If v6 isn't routed into the TUN, the OS sends it directly, unshaped — a bypass. Routing + fast-reject is the correct non-leaky degrade.
The default build uses the pure-Kotlin loopback engine so the project compiles,
runs, and is testable without the Go/NDK toolchain. It establishes the tunnel,
attributes UIDs, records outbound byte stats, applies block-by-drop, and drives the
whole UI — but it does not forward upstream, so controlled apps have no
connectivity under it. Real forwarding + shaping requires the netstack engine
(-Pnetvalve.netstack=true after netstack/build-aar.sh).
DNS is forwarded and, by default, exempt from throttling to protect latency. NetValve observes DNS to populate an IP→hostname cache for future modules but does not currently rewrite DNS. If the user has Private DNS (DoT) enabled system-wide to an external resolver, encrypted DNS still traverses the tunnel but is opaque to domain inspection — expected and documented.
A foreground VpnService is largely Doze-exempt while running, but aggressive OEM
ROMs (Xiaomi/MIUI, Huawei/EMUI, Oppo/ColorOS, Vivo, Samsung/One UI) still kill
background apps and block auto-start. NetValve detects battery-optimization
state and offers the standard exemption prompt plus per-vendor guidance and, if
resolvable, a deep link to the vendor's auto-start settings (BatteryOptimizations).
It cannot silently defeat these (no device-admin, no root) — by design (non-goal).
| Decision | Alternative | Why chosen |
|---|---|---|
| gVisor netstack for the engine | Hand-written TCP stack; tun2socks; lwIP | Requirement: use a mature stack. netstack preserves the original 4-tuple (needed for per-app attribution), unlike a SOCKS boundary; lwIP needs heavy C glue. |
| Throttling in Kotlin at the socket boundary | Throttle inside Go | Keeps all product logic testable in Kotlin and the shaping insertion point singular and explicit. |
| Suspend-to-pace | Drop packets | Preserves app stability (VoIP/streaming/gaming); TCP backpressure does the work. |
| DataStore for rules, Room for stats/logs | All-Room | Rules/settings are small structured blobs (atomic JSON); stats/logs are append/aggregate — Room fits. |
| Two build source sets for the engine | Single module requiring Go always | Lets reviewers build/test the whole app + UI without the native toolchain. |
| minSdk 29 | Lower | getConnectionOwnerUid (attribution) and modern VpnService/FGS semantics require it. |
| Target | Approach in code |
|---|---|
| 300+ concurrent TCP connections | One coroutine pair per flow on Dispatchers.IO; netstack multiplexes; no thread-per-connection. |
| Idle CPU < 2% | Lazy-refill buckets (no timers), suspend-based pacing, stats sampled ~1 Hz, no busy loops anywhere. |
| Throughput overhead < 5% unthrottled | Unlimited direction → null bucket → pace() returns immediately; 16 KB relay chunks. |
| RAM < 50 MB normal | 16 KB relay buffers, bounded UDP queue (256 KB), flow-table + DNS cache capped with eviction, bounded log ring + Room trim. |
| No busy waiting | All waits are delay()/suspension; verified by design. |
| Zero ANRs | No main-thread I/O (StrictMode in debug); startForeground posted immediately on START. |
- Select a heavy app (e.g. a browser or a speed-test app), cap download to 2 Mbps, run a speed test → confirm sustained ≈ 2 Mbps and smooth (no stalls).
- Open 300+ parallel connections (e.g.
iperf3 -Por many browser tabs) → watch Stats → Active and confirm CPU/RAM in Android Studio Profiler stay within targets. - Toggle "background only", background the app → confirm shaping engages; foreground it → confirm it lifts (requires usage access).
- Start another VPN → confirm NetValve shows Revoked and stops cleanly.
- Leave running 30 min screen-off → confirm the tunnel survives (battery exemption granted).