Purpose: This file lets a new engineer or AI agent continue this project with zero prior context. It records what was built, how it was verified, every problem hit (and its fix), the exact environment, and the prioritized remaining work. Read this first; then
README.md→docs/ARCHITECTURE.md.
- Project: NetValve — root-free Android per-app traffic controller (local
VpnServicetunnel, per-app throttle/block/schedule, stats, logs). - State: ✅ All required deliverables complete. ✅ Compiles. ✅ APK built. ✅ 33/33 unit tests pass. ⏳ One optional native artifact (gVisor netstack AAR) is scripted but not yet built (needs Go + NDK).
- Origin thread: Hyperagent thread
cmrwbx7hi0zbz07ad96qne4ye(https://hyperagent.com/thread/cmrwbx7hi0zbz07ad96qne4ye), 2026-07-22.
| Acceptance criterion | Status | Evidence |
|---|---|---|
| Compiles successfully | ✅ | ./gradlew :app:assembleDebug → BUILD SUCCESSFUL (see §3 log, docs/BUILD_VERIFICATION.md) |
| Lists installed apps | ✅ | PackageManagerAppRepository + Apps screen (search, system-app filter) |
| Starts/stops a VPN session | ✅ | NetValveVpnService + VpnController (+pause/resume, restart, onRevoke) |
| Applies throttling rules to selected apps | ✅ | RuleEngine → ThrottleManager token buckets in FlowSupervisor relay |
| Persists selections and settings | ✅ | DataStore JSON; round-trip covered by PersistenceTest |
| Live stats in UI | ✅ | StatsCollector 1 Hz sampling → Dashboard/Stats screens |
| No main-thread blocking / leaks | ✅ (design + StrictMode debug) | StrictModeConfig, IO dispatchers, suspend-based pacing |
| Unit tests (rules, token bucket, persistence) | ✅ 33/33 | 7 suites, run standalone-JVM and under Gradle/AGP |
| Instrumentation test | ✅ compiles (needs device to run) | AppSelectionFlowTest |
| README + sample rules + throttling explainer | ✅ | README.md, docs/sample-rules.json, docs/THROTTLING.md |
The one caveat (by design, documented): the default build uses the pure-Kotlin
loopback engine, which drives everything except actual upstream forwarding.
Real forwarding requires building the gVisor netstack AAR (netstack/build-aar.sh,
needs Go 1.22+ + Android NDK) and building with -Pnetvalve.netstack=true.
This split is intentional so the project compiles/tests without a native toolchain.
The user's spec plus 12 explicit change requests (all implemented):
- Mature stack instead of custom TCP → gVisor netstack via gomobile bridge (tun2socks rejected: loses source 4-tuple → breaks per-app attribution; lwIP rejected: heavy C glue). See
netstack/README.md. - Explicit throttle insertion points → documented pipelines in
docs/THROTTLING.md+FlowSupervisorcomments. Upload: app→TUN→netstack→relay→bucket→upstream.write. Download: upstream.read→bucket→relay→netstack→TUN. - UDP paced not dropped →
PacingQueue(bounded, tail-drop last resort); DNS exempt by default. - Generic policy engine →
RuleCondition(network/roaming/charging/battery/screen/foreground/time/day) ×RuleAction(Allow/Block/Throttle);AppRuleis compiled to generic rules (RuleCompiler). - VPN coexistence →
onRevoke, replace-consent flow, boot re-arm (BootReceiver), process-death recovery (STICKY + persisted enabled flag). - Richer stats → live/avg/peak/session throughput, active/throttled/blocked conns, DNS count, connect latency, last reset (
StatsCollector). - Leveled logging + export →
NetValveLogger(ring buffer + Room, 250 ms rate-limit per hot category, share-sheet export). - Battery/OEM →
BatteryOptimizations(Doze exemption prompt, vendor deep-links for Xiaomi/Huawei/Oppo/Vivo/Samsung), typed FGSspecialUse. - UID fallback chain →
UidResolver: cache →getConnectionOwnerUid(wrapped, never throws out) → single-app inference →UID_UNKNOWNbucket. Unit-tested. - IPv6 explicit → routed into tunnel always (no bypass);
RELAY(default) orFAST_REJECT(immediate RST/ICMPv6 → no timeouts).Ipv6Modein settings. - Plugin architecture →
TrafficModulehooks (flow-open/bytes/close) via Hilt multibindings; block+throttle are themselves modules (DefaultPolicyModule); DNS→IP cache ready for domain rules. Seedocs/EXTENDING.md. - Performance targets → 300+ conns, idle CPU <2%, overhead <5%, RAM <50 MB, no busy-wait, zero ANR; test protocol in
docs/LIMITATIONS.md.
All times local (UTC+3:30), 2026-07-22.
| Time | Event |
|---|---|
| ~20:31 | Plan v1 drafted (custom userspace TCP stack). |
| ~20:44 | User requested 12 changes; plan v2 rewrote engine around gVisor netstack, generic policy engine, UDP pacing, etc. Approved ~20:51. |
| ~20:55 | Toolchain installed in sandbox: Temurin JDK 17.0.19, Kotlin 2.0.21 compiler, Gradle 8.10.2, JUnit console 1.10.2. Gradle wrapper generated (needed --no-validate-url because the wrapper task's URL HEAD check fails behind the egress proxy). |
| 21:00–21:40 | Wrote build system (version catalog, engine-swap source sets), full domain model, policy engine, throttle core, network layer, plugin pipeline, loopback + netstack engines, Go bridge (bridge.go, conn.go, build-aar.sh), data layer, service layer, DI. |
| 21:40–22:00 | Compose UI (5 screens + ViewModels + theme + navigation), 7 unit-test files, 1 instrumentation test. |
| ~22:05 | Standalone JVM verification: 32 framework-free core files compiled with kotlinc; 33/33 tests green via JUnit console. Fixed nothing in core logic — it was correct first pass. |
| ~22:10 | Added stable @SerialName discriminators to sealed RuleCondition/RuleAction (refactor-proof persistence); regenerated + validated docs/sample-rules.json by executing the real serializers. |
| ~22:15 | Docs written: README, ARCHITECTURE, THROTTLING, LIMITATIONS, EXTENDING, netstack/README, LICENSE (Apache-2.0), .gitignore. Static cross-checks: all R.* refs resolve; imports/annotations audited. |
| ~21:52 | First SDK download attempt returned a 48-byte "Session token revoked" page instead of the zip (transient sandbox egress hiccup) → BadZipFile. |
| ~22:03 | Retry succeeded (146 MB cmdline-tools 11076708). |
| ~22:05 | sdkmanager failed: IO exception while downloading manifest. Root cause: sandbox does TLS interception (CN=whoami-sandbox-ca); the CA is in the system PEM bundle but not in the JDK truststore. Fix: extracted the CA from /etc/pki/tls/certs/ca-bundle.crt (cert #146) and keytool -importcert into $JAVA_HOME/lib/security/cacerts (backup at cacerts.orig). Java TLS then worked (HTTP 200). |
| ~22:06 | Licenses re-accepted (first --licenses run had silently accepted nothing while the manifest fetch was failing). Installed platforms;android-35, build-tools;35.0.0, platform-tools (AGP later auto-added build-tools 34.0.0). |
| ~22:07 | Build #1: Gradle daemon OOM-killed at :app:kspDebugKotlin (daemon + Kotlin daemon + KSP exceeded the 4 GB sandbox). Fix: single-JVM config in gradle.properties → org.gradle.daemon=false, kotlin.compiler.execution.strategy=in-process, -Xmx2600m, incremental off. |
| ~22:09 | Pre-build audit caught 2 latent errors before the compiler did: missing import dev.netvalve.throttle.ThrottleManager in TrafficEngine.kt; invalid import androidx.compose.foundation.lazy.item in Dashboard/Stats screens (item is a member fn of LazyListScope, not importable). Fixed. |
| ~22:11 | Build #2: one real compile error — BatteryOptimizations.kt:71 destructuring failed because an explicit Pair<...>? type annotation defeated the ?: return smart-cast. Fix: drop the annotation, let inference smart-cast. |
| ~22:13 | Build #3: BUILD SUCCESSFUL (1m 10s) → app-debug.apk (60,665,802 bytes; copy at prebuilt/app-debug.apk). |
| ~22:15 | :app:testDebugUnitTest → 33 tests, 0 failures (7 suites). :app:compileDebugAndroidTestKotlin → SUCCESS. |
| ~22:19 | Packaged NetValve.zip (sources + docs + wrapper + APK; build dirs & local.properties excluded) and delivered. |
| ~22:31 | This handoff written. |
Everything lives under /agent/workspace:
/agent/workspace/NetValve ← the project (this repo)
/agent/workspace/tools/env.sh ← source this: JAVA_HOME, PATH(+kotlinc), JUNIT_JAR
/agent/workspace/tools/jdk-17.0.19+10 (JDK; cacerts PATCHED with whoami-sandbox-ca; original = cacerts.orig)
/agent/workspace/tools/kotlinc (Kotlin 2.0.21 CLI)
/agent/workspace/tools/gradle-8.10.2 (Gradle dist; wrapper also works)
/agent/workspace/tools/gradle-home (GRADLE_USER_HOME — ~1.6 GB warm dependency cache)
/agent/workspace/tools/android-sdk (cmdline-tools, platform-35, build-tools 35.0.0 & 34.0.0, platform-tools)
/agent/workspace/tools/libs (plain-JVM jars for standalone core testing)
/tmp/gradle-build*.log, /tmp/gradle-test.log (raw build logs from the session)
Canonical build invocation used here:
export ANDROID_HOME=/agent/workspace/tools/android-sdk
export GRADLE_USER_HOME=/agent/workspace/tools/gradle-home
source /agent/workspace/tools/env.sh
cd /agent/workspace/NetValve
./gradlew --no-daemon :app:assembleDebug
./gradlew --no-daemon :app:testDebugUnitTestlocal.properties (sdk.dir=/agent/workspace/tools/android-sdk) exists in the
workspace copy but is intentionally excluded from the zip.
Sandbox rules of thumb (4 GB RAM, 2 vCPU):
- Keep
gradle.propertiesas-is (single JVM, no daemons, no parallel). Re-enabling the daemon/Kotlin-daemon will OOM at KSP. - If any Java process hits TLS errors on a new JDK, re-import the sandbox CA (see §3, 22:05 entry).
- Network is HTTP/HTTPS only;
curlworks out of the box.
JDK 17 + Android SDK (compileSdk 35) is all you need for the default build:
echo "sdk.dir=$HOME/Android/Sdk" > local.properties
./gradlew :app:assembleDebug :app:testDebugUnitTestFor the production engine: Go 1.22+, NDK, then cd netstack && ./build-aar.sh,
then ./gradlew :app:assembleDebug -Pnetvalve.netstack=true.
On a normal machine you may restore daemon/parallel settings in gradle.properties for speed.
A user reported that enabling any bandwidth limit stalled traffic to zero.
Root cause: the loopback engine (default/prebuilt build) never forwards
upstream — not the throttle logic. The token bucket was proven correct via a
faithful JVM simulation (real TokenBucket + RuleCompiler + PolicyEvaluator
through the exact pace() loop): every realistic cap achieves ≈1.0× the rate,
never zero. Full write-up + the 9-point verification is in
docs/DEBUG_THROTTLING.md. Instrumentation added this pass: engine identity +
BuildConfig.USE_NETSTACK logged at tunnel start; loopback periodic dropped-byte
WARNING; ThrottleManager DEBUG state logs + a 2 s single-wait clamp;
Logger.isEnabled; a Dashboard "loopback" banner; regression test
TokenBucketTest.sustainedThroughputMatchesRateNotZero. If throttling ever
"stalls" again: first confirm the engine (Logs screen / banner), then read that doc.
The production gVisor engine is now built and integrated (was previously source-only). Key facts:
- Engine =
github.com/sagernet/gvisorfork (upstreamgvisor.dev/gvisoris NOT plain-go/gomobile buildable — Bazel-generated lists/refcounts/marshal aren't committed;pkg/syncships.tmpl.stemplates).bridge.go/conn.goimport the fork;go.modpinsv0.0.0-20250811.0-sing-box-mod.1. netstack/build-aar.sh(verified) →app/libs/netstack.aar(3.8 MB, arm64libgojni.so). NeedsANDROID_NDK_HOME+GOTOOLCHAIN=auto(auto-fetches Go ≥1.25)../gradlew :app:assembleDebug -Pnetvalve.netstack=true→ BUILD SUCCESSFUL;prebuilt/app-netstack-arm64-debug.apk(66 MB) contains the engine.- Two adapter fixes vs. current fork API:
udp.NewForwarderhandler returns(handled bool);TCPConn.readislong+ EOF-as-exception → converted inNetstackPacketPipeline.TcpConnStream. - Validation: forwarding+throttling proven on real sockets (host) + a runnable
on-device test (
ThrottleForwardingInstrumentedTest). Physical-device 3-app run is NOT possible in this sandbox (no device/KVM) — seedocs/NETSTACK_EVIDENCE.mdfor the Firebase Test Lab command + manual protocol. Do NOT fabricate device results.
Netstack engine forwarded traffic but stalled after seconds. Root cause: relay
thread‑pool starvation — 2 blocking coroutines per flow on Dispatchers.IO
(64‑thread cap); a busy page's concurrent keep‑alive flows exhaust it (~31 flows
→ stall). Fix: relay scope now uses Dispatchers.IO.limitedParallelism(512)
(TrafficEngine.RELAY_PARALLELISM). Reproduced on real sockets: 64→31/120 served,
512→120/120. Also hardened the Go bridge: panic‑recover in both forwarders, wired
the Log callback to the Kotlin Logger, and a 5 s liveness beacon
([netstack] netstack alive: tcpFlows/udpFlows) for triage. Full write‑up +
device confirmation steps: docs/NETSTACK_EVIDENCE.md (Runtime fix section).
Rebuilt AAR + prebuilt/app-netstack-arm64-debug.apk. Future scale/RAM: NIO
upstreams instead of blocking sockets.
- Engine source-set swap:
app/build.gradle.ktsaddssrc/loopback/kotlinorsrc/netstack/kotlinbased on-Pnetvalve.netstack=true. Both definedev.netvalve.di.EnginePipelineModule— never let both into one compilation. LazyListScope.itemis not importable — don't re-addimport androidx.compose.foundation.lazy.item(onlyitemsis a top-level extension). Bit us once.- Kotlin smart-cast: explicit nullable type annotations on
val x: T? = when{...} ?: returnpatterns defeat smart-casts (theBatteryOptimizationsfix). Prefer inference. - gomobile naming: Go
bridgepackage binds to Javadev.netvalve.bridge;NewTunnel→Bridge.newTunnel, methods lower-cased,(int, error)→int+ checked exception.NetstackPacketPipeline.ktmust match the AAR'sclasses.jarif the Go API changes. - gVisor has no stable API —
go.modpins a placeholder commit; when first building the AAR rungo mod tidy/go get gvisor.dev/gvisor@<commit>and expect small call-site fixes inbridge.go(forwarder signatures move).build-aar.shrunsgo build ./...first so drift fails fast. - Per-app list is establish-time-fixed (platform): selection changes →
VpnActions.ACTION_RESTART(seamless rebuild). Cap/rule edits do NOT need a restart (live bucket refresh viaRuleEngine.revision). - Never route around IPv6 — it is always routed into the TUN (RELAY or FAST_REJECT); "don't add the v6 route" would create a shaping bypass.
NetValveLoggerrate-limits DEBUG/INFO in hot categories (250 ms per category+uid) — don't "fix" missing repeated lines; WARN/ERROR always pass.- Room
exportSchema = falseintentionally (no schema dir); flip it on if you start shipping migrations. - Loopback engine adopts the TUN fd (
ParcelFileDescriptor.adoptFd) andTrafficEnginehands itdetachFd()— ownership is the engine's; don't also close it in the service. - Do not throttle DNS by default (
exemptDns=true) — resolution latency multiplies into every app's connect time. - The two same-named
EnginePipelineModulefiles are the ONLY duplicated symbol; everything else is single-source.
- Build the netstack AAR (unblocks real forwarding — the only missing piece of the production path):
Go 1.22+,
export ANDROID_NDK_HOME=...,cd netstack && ./build-aar.sh, fix any gVisor API drift, then./gradlew :app:assembleDebug -Pnetvalve.netstack=true. Smoke-test on device: throttle a browser to 2 Mbps, run a speed test. - On-device validation (needs emulator/device):
./gradlew :app:connectedDebugAndroidTest; then the manual protocol indocs/LIMITATIONS.md(300+ conns, CPU/RAM targets, revoke/reboot flows, background-only rule with usage access). - Warning-threshold notifications:
AppRule.warnThresholdPercentis persisted and surfaced in the verdict (FlowVerdict.warnThresholdPercent), but no notification fires yet. Implement as aTrafficModuleobservingonBytes+ a daily/session usage store, posting a notification at the threshold. (Deliberately left as the first exercise of the plugin API.) - Stage-2 niceties: netstack retransmit counters into stats (bridge exposes
Stats()cheaply), per-app schedule UI for multiple windows, quota module (sketch indocs/EXTENDING.md), domain filtering on the existingDnsCache. - Polish: app icon densities beyond adaptive XML, per-app detail chart, localization (all strings currently in code for detail screens — move to
strings.xmlif localizing).
TokenBucketTest— refill math, burst cap, debt pacing, live rate update (fake clock).PacingQueueTest— FIFO, DROP_NEWEST/DROP_OLDEST, oversize, byte accounting.PolicyEvaluatorTest— precedence (block>throttle, allow-override), condition gating, midnight-wrapping windows.RuleCompilerTest— AppRule→PolicyRule compilation incl. background-only and schedules; global default action.UidResolverFallbackTest— cache, -1 fallback, single-app inference, exception safety.PersistenceTest— exact unit conversions; settings/rule/sealed-hierarchy JSON round-trips.FormatTest— bytes/rate/duration formatting.AppSelectionFlowTest(androidTest) — Compose render + selection toggle flow.
Standalone-JVM re-run (no Gradle): see docs/BUILD_VERIFICATION.md §core; jars in /agent/workspace/tools/libs.
- Root:
README.md,HANDOFF.md(this),LICENSE,.gitignore,settings.gradle.kts,build.gradle.kts,gradle.properties(low-mem tuned),gradle/libs.versions.toml, wrapper. - app/:
build.gradle.kts(engine flag, Hilt/KSP/Compose),proguard-rules.pro,src/main/AndroidManifest.xml(VpnService + typed FGS + boot receiver). - app/src/main/kotlin/dev/netvalve/
MainActivity.kt(consent/permission launchers →AppActions),NetValveApp.kt,StrictModeConfig.ktdata/model/— serializable domain (enums,BandwidthLimit+units,RuleCondition,RuleAction,AppRule+Schedule,GlobalSettings,EvaluationContext,InstalledApp)data/datastore/,data/db/— DataStore singleton + JSON config; Room entities/DAOs/dbrepository/— interfaces + DataStore/Room/PackageManager implsrules/—PolicyEvaluator(pure),RuleCompiler,RuleEngine(orchestrator),DeviceState(+monitor iface),PolicyModelsthrottle/—TokenBucket(lazy refill),PacingQueue,ThrottleManager(live refresh,pace())network/—PacketPipeline(+factory) boundary,FlowContract(streams,TunnelConfig,SocketProtector),FlowSupervisor(relay + insertion points),ConnectionManager(protected upstreams),UidResolver,DnsCachemodule/—TrafficModuleAPI +ModuleChain+DefaultPolicyModule/ConnectionLogModulestats/—StatsCollector,ThroughputMeter, snapshot modelslog/—LoggerAPI +NetValveLoggerservice/—NetValveVpnService,VpnController,TrafficEngine(session assembly),DeviceStateMonitorImpl,BatteryOptimizations,BootReceiver,NotificationHelper,TunnelStatedi/—DataModule,RepositoryModule,EngineModule,ModulesModule,Qualifiersui/— theme, navigation(+AppActions), components, dashboard, apps, appdetail (RuleDraftmapper), stats, logs
- app/src/loopback|netstack/ — the two swappable engines (each with its own
EnginePipelineModule). - app/src/test|androidTest/ — tests (§7).
- netstack/ —
bridge.go,conn.go,go.mod,build-aar.sh,README.md. - docs/ —
ARCHITECTURE.md,THROTTLING.md,LIMITATIONS.md,EXTENDING.md,BUILD_VERIFICATION.md,sample-rules.json(generated by executing the real serializers). - prebuilt/ —
app-debug.apk(loopback engine, built 2026-07-22).
Rebuild + tests (sandbox) — §4a commands.
Add a feature module (quota/domain/etc.): implement TrafficModule, bind
@Provides @IntoSet in di/ModulesModule.kt, unit-test pure logic. Full guide:
docs/EXTENDING.md.
Change a rule field: model in data/model/AppRule.kt (+@SerialName stability),
compile in rules/RuleCompiler.kt, edit UI in ui/appdetail/RuleDraft.kt + screen,
round-trip test in PersistenceTest.
Debug why an app isn't throttled: Logs screen at DEBUG → look for
RULE_MATCH/CONNECTION_OPEN lines with the app's uid; check UidResolver
failures (Unknown bucket ⇒ attribution fell through); confirm the app is in the
controlled set (selection mode!) and the rule's conditions match current device state.