Architecture
Tetherand splits into three independently shippable subsystems:
- Tether (milestones M1–M2) — the reverse-tether to a laptop.
- Privacy chain (M3–M6) — composable VPN hops.
- Threat detection (M7, M9, M10) — on-device monitors and hardened-mode lockdown.
Each subsystem works on its own. You can use the Threat tab without ever enabling the privacy chain; you can use the privacy chain without tethering to a laptop; you can use the tether with no chain and no monitoring.
Process layout
The Android side runs a single process with several foreground services:
dev.tetherand.app (single process)
├─ MainActivity (Compose UI, 4 tabs)
├─ TetherandService (VpnService — reverse tether)
├─ TetherandChainService (VpnService — privacy chain)
├─ ThreatDetectionService (specialUse FGS — monitors)
├─ ClipboardScrubberService (specialUse FGS — prompt-injection)
├─ DecoyListenerService (specialUse FGS — Hardened Mode honeypot)
├─ HardenedTileService (QuickSettings tile)
└─ AoaAccessoryService (USB-accessory receiver)
Only one VpnService can be active at a time — Android enforces this. The user picks which via the bottom-nav tabs.
The host (Mac/Linux) side runs tetherand as a CLI:
tetherand-cli
├─ adb reverse (USB-ADB transport)
├─ tetherand-relay-core (userspace TCP/UDP/ICMP stack,
│ forked from Genymobile/gnirehtet)
├─ tetherand-transport-{bt,aoa,tcp} (other transports)
└─ ratatui dashboard (`tetherand tui`)
Tether data flow
┌──────── 5364C13D (dev.tetherand.app APK) ────────┐
│ TetherandService (VpnService) │
│ │ TUN (10.0.0.2) │
│ ▼ │
│ PersistentRelayTunnel ───► LocalSocket │
│ "tetherand" │
└─────────────────────────────────┼───────────┐
│ adb reverse
▼
┌──────── Mac (bin/tetherand) ───────────────┐
│ tetherand-relay-core │
│ │ userspace TCP/UDP/ICMP │
│ ▼ │
│ Real internet (host Wi-Fi/Ethernet) │
└──────────────────────────────────────────┐
The 5364C13D's VPN TUN receives every packet the device wants to send. The persistent relay tunnel hands it over adb reverse to a TCP socket on the laptop, where the relay-core (forked from Gnirehtet, Apache-2.0) maintains a userspace TCP/UDP/ICMP stack and forwards through the host's normal network egress.
Bluetooth-RFCOMM, USB-AOA, and LAN-TCP transports plug in at the LocalSocket boundary instead of adb reverse. The data shape is the same — raw IP packets delimited by the IPv4 header length field — so the relay-core doesn't change.
Privacy chain data flow
The chain orchestrator (dev.tetherand.app.chain.ChainOrchestrator) defines a generic Hop interface:
interface Hop {
val id: String
val displayName: String
val caps: HopCaps
suspend fun start(input: Channel<ByteArray>): Channel<ByteArray>
suspend fun stop()
}
Each hop consumes IP packets from its input channel and emits processed IP packets on its output channel. The orchestrator wires them in sequence, so the user-visible chain is just a list of hops:
TUN → WireGuardHop → MullvadHop → NymHop → TorHop → Internet
Each hop also exposes its own capability flags (supportsPQ, supportsMultihop, supportsAntiCensorship), which the UI uses to build the chain-status badge.
The native crypto and protocol logic for each hop lives in a Rust crate at relay/:
| Crate | Hop | What it does |
|---|---|---|
relay/wg | WireGuardHop | BoringTun WireGuard userspace, JNI'd |
relay/tor | TorHop | arti-client 0.27, JNI'd |
relay/nym | NymHop | nym-sdk gated by with-sdk feature, JNI'd |
relay/pt-bridge | (auxiliary) | obfs4 / meek / webtunnel binary, spawned by Arti's PT manager |
The chain orchestrator never sees the underlying crypto; it only sees IP packets in and IP packets out.
Threat detection data flow
The threat-detection foreground service runs five collectors concurrently:
CellInfoSource ───┐
LocationSource ───┤
WifiScanner ───┼───► Heuristic orchestrator ───► ThreatDb (Room)
BluetoothScan ───┤ │
AppAudit ───┘ ▼
ThreatScreen
Each collector emits observations into a coroutine flow. The heuristic orchestrator zips observations across collectors when a heuristic needs cross-source data (for example, "TAC change without motion" needs cell observations and accelerometer observations on the same timeline). Verdicts get written to a Room-backed ThreatDb which the Threat tab observes via Compose collectAsState.
The per-location baseline lives in the same database, keyed by a six-character geohash. Baseline writes happen automatically as observations arrive; baseline reads happen when a heuristic needs to know "what's normal for this geohash". No network calls.
On-device file layout
/data/data/dev.tetherand.app/
├─ files/
│ ├─ arti/ # Arti's cache + state directory
│ ├─ nym/ # Nym SDK state
│ └─ databases/
│ ├─ threats.db # Room-backed alert feed + baseline
│ └─ …
├─ cache/
│ ├─ arti/ # arti cache subdir
│ └─ pts/ # extracted PT binaries (chmod +x)
└─ shared_prefs/
├─ tetherand-hardened.xml # EncryptedSharedPreferences
├─ tetherand-nym.xml
├─ tetherand-tor-bridges.xml
├─ tetherand-voiceprint.xml
└─ …
The encrypted SharedPreferences files use the AndroidX security library: AES256-SIV for key encryption, AES256-GCM for value encryption, keys held in the AndroidKeyStore (hardware-backed where available).
Subsystem boundaries
The licensing converges towards GPL-3.0-or-later from the moment M7a links in (NetMonster reflection and the AIMSICD port). M1 and M2's tether code is Apache-2.0 in isolation, but the shipped APK ends up GPL-3.0-or-later once the threat module is included. See Licensing for the full per-milestone map.