What the layers are, which way they depend, and the rules that keep them apart.
ui/ screens, theme, icons, presentation state
↓
vpn/ VpnService · TUN configuration · lwIP bridge · flow handler · tile
↓
data/ NodeStore · SubscriptionStore · NodeRepository
nodes/ NodeHealth · Failover — which node carries the traffic
routing/ rules, the direct path, the reject path
dns/ interception, the fake-IP pool, both families
apps/ which applications the tunnel carries
↓
net/ NowhereDialer — TCP, Morph, TLS, certificate verification
↓
protocol/ the wire. auth · frame · morph · quic · session · target · tls · url
subscription/ the dashboard's HTTP surface, and its scheduled refresh
Six of those packages carry the 90% coverage gate rather than one:
protocol/, subscription/, dns/, apps/, routing/ and nodes/. They are
not all the wire, but they are all the same kind of code — a wrong answer is
wrong silently, and in routing/ and nodes/ it is wrong in the worst
direction, with traffic leaving the device somewhere the user did not ask.
Dependencies point down and never up. protocol/ knows nothing about
Android — no Context, no resource, no Log — which is why every protocol test
runs on the JVM in milliseconds and can be pointed at a real Portal without a
device. If something in protocol/ ever needs a string for a person to read,
that is the signal that it belongs a layer up.
The wire format and the session. This is the part where being wrong means being wrong on the wire, and it carries the 90% coverage gate for that reason.
Two conventions run through all of it:
- Sealed results, never exceptions. Every fallible operation returns
DecodeResult<T>, which is eitherOkor anInvalidcarrying a named reason. Under fuzzing, "correctly rejected malformed input" and "crashed" are the same observation if failures are exceptions — and telling those apart is the entire value of fuzzing a parser. - Each rejection is its own type.
SetupResult's four refusals are an enum with per-value meaning rather than an error carrying a number, so that collapsing them is not the path of least resistance. They have to stay distinguishable all the way to the screen.
Two carriers, chosen by the node's net: TLS, one connection per flow
(DedicatedTlsLane), and QUIC, one connection for all of them (QuicCarrier).
NowhereSession picks between them and owns nothing else — v1's session id and
flow-id allocator went with the Mux and split carriers they existed for.
morph/ is the v3 wire mask (ADR-0003); tls/PeerVerification is the one
certificate decision both carriers hand their chain to.
The dashboard's HTTP surface: preparing a request, reading a feed, parsing the
quota header. Separate from protocol/ because it speaks a different protocol
to a different party — a dashboard is not a Portal, and the two fail in
unrelated ways.
SubscriptionEndpoint also owns redaction: the subscription URL is a bearer
credential, and it is the only thing that knows how to mention one safely.
NowhereDialer — the one place that opens a TLS connection: bind, protect,
connect, Morph, then TLS layered over the masked socket with Conscrypt in
engine mode, which is the only layered mode that goes through the wrapper's
streams (ADR-0003). The socket factory, the ALPN and the exporter all come from
that one stack.
It is also where verification happens for TLS, through PeerVerification and
PlatformServerTrust, before the dialer returns — a check after the first
Nowhere byte is written is a log entry, not a check. quic/QuicDial is the same
decision for QUIC.
Storage and the observable state over it.
NodeStorepersists a node as the URL text it arrived as, re-parsed by the same parser. Keeping the text meansNowhereUrl.parsestays the only definition of a valid node; a serialised struct would be a second one, and the two drift.SubscriptionStorekeeps the credential, in its own file.NodeRepositoryis the seam the UI talks to. It owns an application-lifetime scope, because a subscription fetch must outlive the screen that started it.
Compose. Three rules that are not obvious from the code:
- Screens read state and render it. Anything that reaches the network goes through the repository, on the repository's scope.
- Every screen has a data-free half —
Home,NodeList,NodeEditor,DiagnosticsScreenall take plain values — so previews and the design-rule instrumentation suite can render the design without a repository behind them. - Presentation of a failure lives here, not below.
DecodeReason.detailis a diagnostic string: English, type names, written for a log. Mapping a reason to a sentence a person reads isui/state/DialReasonText.kt's job, and shippingdetailto a screen is a defect that has already happened once.
VpnService and the TUN / lwIP layer. Until they exist nothing routes device
traffic, and the home screen reports a session that is honestly disconnected.
The layer will sit beside net/ and feed protocol/; nothing above it should
need to change to accommodate it, which is the test of whether these boundaries
were drawn in the right places.
| Layer | How it is tested |
|---|---|
protocol/ |
Known-answer vectors, fuzzing, and sixteen tests against a live Portal — the failures that matter are the ones no fixture predicts |
subscription/ |
A real local HttpServer, so the tests see the bytes actually put on the wire |
net/ |
Against a live Portal, including pins verified against a fingerprint read over an independent connection |
data/ |
Temporary directories and a real HTTP server; the round trip is fuzzed, because storage inherits every rounding error the round trip has |
ui/ |
Contrast and i18n rules as unit tests; the four design rules as instrumentation tests on a device |