Architecture overview
Haven is a single-module Android app with a strict layering rule and a deliberately small server footprint.
The layering rule#
presentation/ Compose screens, ViewModels, navigation, theming, the Sam renderer
│
├── domain/ Pure Kotlin. No Android imports. All the decisions live here.
│
├── data/ Room, DataStore, Retrofit. Persistence and I/O.
│
└── filter/ The Android service shells: VPN, accessibility, boot receiver
└── security/ PIN, hashing, device admin, tamper policy gluedomain/ contains no Android types. Every rule that decides something — is this hostname gambling, should this app be paused, may this disable proceed, is this catch strong enough to need a cooldown, has this exercise been completed — is a pure function there.
This is not stylistic. It is what makes the protection testable: the bulk of the project's 70 unit-test files sit on domain/, and they run without an emulator. A service shell that holds no decisions cannot hide a bug in a place tests can't reach.
The pattern repeats everywhere: the accessibility service is plumbing that feeds cached settings into pure policy objects, and the VPN service is a packet loop that asks a pure matcher.
The main data flow#
┌───────────────────────────────┐
│ signed blocklist delta (12h) │
└──────────────┬────────────────┘
│ verify signature, reject rollback
▼
bundled seed ──▶ Room: block_rules ──▶ immutable rule-set snapshot
▲ │
user's own filters ───┘ │ atomic swap
learned domains ──────┘ ▼
DomainMatcher (in memory)
▲ ▲
┌───────────────────────────┘ └──────────────┐
│ │
VPN DNS packet loop accessibility service
hostname ─▶ block? ─▶ NXDOMAIN foreground package / URL bar / page
│ │
└──────────▶ block log ◀───────── risk engine verdict ──┘Two properties of that diagram are load-bearing:
- The rule set is an immutable snapshot swapped atomically, so a blocklist update takes effect instantly without ever locking the DNS hot path and without restarting the tunnel.
- The app-detection layer and the DNS layer read the same matcher instance. That is why a newly-listed operator's app is recognised without an app update, and why the App-check list can never disagree with the live blocking.
Keeping the hot path fast#
The DNS packet loop answers queries with minimal latency, so nothing slow is allowed on it:
- Block decisions are hash-set probes against an in-memory snapshot.
- Persisting a block event is a non-blocking enqueue onto a bounded channel, drained by a single background consumer. Under extreme load the oldest queued events are dropped — the log is best-effort transparency data, never something protection depends on.
- The single upstream socket is created once, protected from the tunnel, and reused across queries, and is reset on any error so a timed-out reply cannot bleed into the next query.
Similarly, the accessibility service's event callback never reads persistent storage. Settings are observed reactively and cached in memory, and the cheap checks (is this a browser, is protection on) run before anything expensive.
Offline-first#
The bundled seed blocklist means protection works on first launch, before any network call, and a device that is never online is still protected. Remote deltas top it up. A failed sync leaves the cached rules working.
The same principle runs through the app: no feature except the streak, the paywall and account operations needs the network, and none of them can turn protection off by failing.
What runs off the device#
Very little, and none of it is in the blocking path:
| Off-device | Why it cannot affect blocking |
|---|---|
| Signed blocklist deltas | Verified against a pinned key; a failure leaves the cached rules in place |
| Optional accounts | Auth never gates protection; a signed-out user is fully protected |
| The Pro entitlement | Only gates Pro features; a failed check writes nothing |
| The streak | Cosmetic progression only |
| The welcome email | Fire-and-forget; account creation never depends on it |
See Backend and sync and Data handling.
Dependency injection and lifecycle#
Hilt wires the graph. A few objects are process-wide singletons because two components must share exactly one instance — the domain matcher (VPN loop and sync worker), the block-event logger (DNS layer and accessibility layer), and the entitlement and progression managers.
Some cross-component signalling deliberately uses tiny in-memory holders rather than persistence: whether the user just proceeded through a financial pause, whether they just asked to uninstall a flagged app, and a queue of partner emails raised from a screen that closed immediately. These are non-persistent on purpose — a grace period should not survive a reboot.