NetPilot puts the missing Private DNS (DNS-over-TLS) controls of Android TV — and a clean, profile-based VPN manager — behind one beautiful, remote-control-first interface. 100% first-party code: no third-party runtime libraries beyond Google's own AndroidX/Material, no analytics, no ads, no telemetry, no network calls of its own.
Built for Android TV (D-pad/remote first), running everywhere else too — phones and tablets, Android 9.0+ (API 28) → Android 15 (API 35), all densities and screen sizes (24"–80"+ TVs, phones, tablets), light & dark themes.
Most Android TV builds hide the Private DNS settings screen — but the feature itself is alive and
well inside the OS since Android 9. NetPilot drives the system settings directly
(Settings.Global → private_dns_mode / private_dns_specifier), the same keys the hidden menu writes.
Because Android (correctly) protects these settings, a one-time permission grant over ADB is needed — no root, ~2 minutes, survives reboots and app updates:
adb shell pm grant app.netpilot android.permission.WRITE_SECURE_SETTINGSThe app walks the user through this with a built-in, copy-paste setup guide and verifies the grant live. Without the grant, everything is still browsable and the VPN works — only system DNS writes are held back.
Zero-setup Secure DNS (no ADB, no computer): a first-party DNS-over-TLS tunnel inside
VpnService — Android asks for VPN permission once (a normal dialog, remote-friendly) and every
DNS lookup is encrypted to your selected provider. Bootstrap resolution happens before the tunnel
comes up, so the provider can never recurse into the tunnel. Limited to DNS, nothing else is routed.
System Private DNS (strongest, one-time ADB): drives the real Settings.Global keys —
device-wide, kernel-enforced, also covers apps that ignore per-app DNS. Grant:
adb shell pm grant app.netpilot android.permission.WRITE_SECURE_SETTINGSNo computer? Install Termux on any Android phone → pkg install android-tools →
adb connect <TV_IP>:5555 → run the grant from the phone over Wi-Fi.
Common to both modes:
- Multiple profiles (name + DoT hostname, e.g.
dns.google,one.one.one.one,dns.adguard-dns.com) - Single-active invariant: activating one profile automatically deactivates the previous one — exactly one provider is ever live
- Modes: Off · Automatic (opportunistic) · Profile (strict hostname)
- Input validation with dedicated errors for "that's an IP, Private DNS needs a hostname"
- Live system-state sync via
ContentObserver(catches out-of-app changes instantly)
| Android | WireGuard (embedded) | IKEv2 (platform) | OpenVPN |
|---|---|---|---|
| 9 / 10 (TV focus) | ✅ in-app, one app | — (needs 11+) | ✅ engine bridge · embedded core in development |
| 11+ | ✅ in-app, one app | ✅ in-app (platform engine) | ✅ engine bridge · embedded core in development |
- Native platform IKEv2/IPsec (Android 11+): the OS implements the protocol — NetPilot only
provisions and starts/stops it via
VpnManager/Ikev2VpnProfile(PSK or username/password + CA cert) - OpenVPN profiles: import
.ovpnfiles with a built-in first-party config parser (remotes, ciphers, inline CA/cert/key blocks, auth style) - WireGuard — embedded engine, ONE app: the official
wireguard-androidtunnel library (Apache-2.0, userspacewireguard-go) ships inside NetPilot. Import your server's.conf(WgConfigCheckpre-validates it), connect, done — this is the DEFAULT type on Android 9/10, where the platform IKEv2 API does not exist. Runs through NetPilot's own state machine, notification and Turn-off action; the server needs WireGuard enabled (every serious server/provider supports it alongside OpenVPN) - OpenVPN: embedded engine (v2.3.0) — the official OpenVPN 3 C++ core (AGPL-3.0) is compiled
by CI into
libovpncore.soand linked behind thecore.vpn.VpnDataChannelseam: import a.ovpn, connect in-app, one app, Android 9+. Release builds ship the engine; the connection state is the core's own CONNECTED event, never a guess. On builds without the native library NetPilot drives the official open-source OpenVPN for Android app through its documented external control API instead (same profile, honest labeling either way) - Android keeps one VPN active system-wide; the UI mirrors that honestly — an engine-app tunnel is reported as "Engine VPN active", never as NetPilot's own
- Left navigation rail on TV (Netflix-style), bottom navigation on phones/tablets
- Every control is D-pad reachable; focus ring + lift + zoom make focus unmistakable at 10 feet
- Big type, ≥48 dp targets, high-contrast palettes for both themes
- Dashboard: protection state hero, quick toggles, live network facts (interface, effective DNS)
- Quick Settings tiles: one-tap Private DNS and VPN toggles from the shade — boundaryless, run only while the shade is open (Android 9+; subtitles on 10+)
- Per-app VPN (WireGuard): include/exclude which apps use the tunnel — written into the
profile's
.confasIncludedApplications/ExcludedApplications, applied natively by the engine - Restart-proof access: the ADB grant survives TV power cycles; if access is ever lost
(Shizuku-only setup after a restart, cleared data), the first-launch guide re-appears
automatically with the fix (see
docs/ACCESS-PERSISTENCE.md) - Optional boot reconnect (Settings, default off): one reconnect attempt at power-on for the embedded WireGuard tunnel — no background retry loops
- App shortcuts: long-press the icon → Private DNS / VPN
- Zero-dormancy battery: when nothing is protecting, nothing runs or listens — no background
services, listeners, polling, or wake locks (see
docs/PERFORMANCE.md) - Settings: theme (system/light/dark), permission status, profile export/import (JSON via SAF), privacy statement, in-app open-source licenses
- Advisory (not a blocker) when VPN + strict Private DNS run together — they can coexist; strict DoT then encrypts lookups even inside the tunnel
git clone <this repo> && cd netpilot
./gradlew :app:assembleDebug # → app/build/outputs/apk/debug/app-debug.apk
./gradlew :app:testDebugUnitTest # 75 JVM tests (JUnit + Robolectric)
./gradlew :app:lintDebug # Android Lint gateOr just push — GitHub Actions builds, tests, lints and scans on every commit (see below).
- Sideload the APK (Settings → Apps → Install unknown apps on TV, or
adb install app-debug.apk) - Open NetPilot → follow the one-time setup guide for the ADB grant
- Add a DNS profile (e.g. Cloudflare /
one.one.one.one) → activate. Done.
| Workflow | What it does |
|---|---|
CI (ci.yml) |
Unit tests → Lint → APK build → instrumented tests on real emulators (API 29 & 34, one leg with the ADB permission granted to exercise true system writes) |
Security (security.yml) |
Gitleaks secret scanning, PR dependency review (vuln + license gate), build-hardening assertions (no cleartext, no debuggable, backups stay excluded) |
Release (release.yml) |
Tag v* → verification gate → signed APK + AAB + SHA-256 checksums + changelog; refuses to publish unsigned |
| CodeQL | Static security analysis (Java/Kotlin, security-extended) |
Signing uses repository secrets (ANDROID_KEYSTORE_B64, …) — never committed files.
Full details: docs/RELEASE.md, docs/SECURITY.md.
- docs/ARCHITECTURE.md — module map, data flow, the 98%-first-party breakdown
- docs/DESIGN_SYSTEM.md — palettes, tokens, components, focus/TV rules
- docs/SECURITY.md — threat model, permissions, data storage, dependency policy
- docs/TESTING.md — the test pyramid and how to run every layer
- docs/RELEASE.md — signing + publishing runbook
- docs/TV_NAVIGATION.md — D-pad interaction map
- docs/VERIFYING.md — how to verify the APK is real, signed and runs
App code: MIT (see LICENSE). Runtime dependencies: first-party Android libraries only (AndroidX, Material Components, Kotlin stdlib — Apache 2.0). Build/test tooling (Gradle, JUnit, Robolectric, Espresso) is open-source but never ships in the APK; the full list lives in-app under Settings → Open-source licenses.
