NetValve is built so new traffic behaviour is added without touching the VPN engine or the relay. There are two extension surfaces.
Conditions and actions are sealed, serializable hierarchies.
New condition (e.g. "metered network only"):
- Add a subclass in
data/model/RuleCondition.kt:@Serializable data class RequireMetered(val metered: Boolean) : RuleCondition { override fun matches(ctx: EvaluationContext) = ctx.metered == metered override fun describe() = if (metered) "on metered" else "on unmetered" }
- Add the backing field to
EvaluationContextand populate it inDeviceState.toEvaluationContext(+DeviceStateMonitorImpl). - Done —
PolicyEvaluatoralready evaluates any condition; no evaluator change.
New action (e.g. MarkMetered): add it to RuleAction, then teach
PolicyEvaluator.fold and FlowVerdict how to carry it. This is the only place
the engine learns a new kind of outcome.
Implement TrafficModule and bind it into the Hilt multiset. The ModuleChain
runs every module in priority order for each flow.
class QuotaModule @Inject constructor(
private val quotas: QuotaStore, // your own persistence
) : TrafficModule {
override val name = "daily-quota"
override val priority = 10 // after DefaultPolicyModule (0)
override fun onFlowOpen(input: FlowOpenInput, verdict: FlowVerdictBuilder) {
if (quotas.isExceeded(input.uid)) verdict.block() // veto
}
override fun onBytes(ctx: FlowContext, uid: Int, direction: Direction, bytes: Int) {
quotas.add(uid, bytes) // hot path: keep O(1)
}
}Register it:
// di/ModulesModule.kt
@Provides @IntoSet
fun provideQuotaModule(store: QuotaStore): TrafficModule = QuotaModule(store)That is the entire integration — no changes to FlowSupervisor, PacketPipeline,
or ThrottleManager.
| Hook | When | Notes |
|---|---|---|
onFlowOpen(input, verdict) |
new flow, after UID attribution | contribute block/caps/tags; runs by ascending priority. |
onBytes(ctx, uid, dir, bytes) |
each relayed chunk | hot path — O(1), allocation-free. |
onFlowClose(ctx, uid) |
flow ends | final accounting/cleanup. |
FlowVerdictBuilder folds contributions: caps take the minimum across modules,
block() is a veto — so composition is order-independent for the common case.
- Per-app quotas —
onBytesaccounting +onFlowOpenveto (sketch above). - Domain filtering / firewall — a
DnsCache(IP→hostname) is already populated from observed DNS. ADomainFilterModulecan consult it inonFlowOpenand block by hostname pattern. - Parental controls — schedule + domain modules combined, gated by a PIN screen.
- Adaptive throttling — a module that watches
StatsCollectorthroughput/RTT and callsThrottleManagerto adjust caps dynamically (the buckets already support liveupdateRate). - Alternative engines — implement
PacketPipeline(+PacketPipelineFactory) in a new source set to swap in a different stack; nothing else changes.
Keep new logic framework-free where possible and unit-test it like the core (see
app/src/test). Modules that only use TrafficModule hooks + your own stores are
pure JVM and need no emulator.