# DragonTCP Hybrid

DragonTCP carries ordinary TCP traffic inside a compact binary record protocol,
normally over TCP port 53. It has three parts:

* a **Go server** on a Linux host that relays streams to their real destinations,
* a **Go client** that exposes a local HTTP/HTTPS proxy,
* an **Android app** that captures all device traffic with `VpnService`, runs a
  userspace TCP/IP stack, and feeds everything into that local proxy.

This document is a complete reference for how all of it works.

---

## Table of contents

1. [Overview](#1-overview)
2. [Security model](#2-security-model)
3. [Repository layout](#3-repository-layout)
4. [The wire protocol](#4-the-wire-protocol)
5. [The Go client](#5-the-go-client)
6. [The Go server](#6-the-go-server)
7. [The Android app](#7-the-android-app)
8. [Building](#8-building)
9. [Running](#9-running)
10. [Configuration reference](#10-configuration-reference)
11. [Tuning](#11-tuning)
12. [Troubleshooting](#12-troubleshooting)
13. [Testing](#13-testing)
14. [Licensing](#14-licensing)

---

## 1. Overview

### 1.1 The data path

```text
  Android apps (any app, unmodified)
        │
        │  IP packets
        ▼
  VpnService TUN interface                    10.77.0.2/32, MTU 1400
        │
        │  userspace TCP/IP reassembly
        ▼
  TunnelEngine  (Kotlin, in-process)
        │
        │  HTTP CONNECT to 127.0.0.1:8080
        ▼
  dragontcp-client  (Go, child process on the phone)
        │
        │  DragonTCP binary records over TCP/53
        ▼
  dragontcp-server  (Go, on the host)
        │
        │  plain TCP
        ▼
  destination server
```

### 1.2 Design decisions

**The server is only a relay.** It creates no TUN device, performs no NAT, and
needs no `iptables` rules. All packet-level work happens on the phone in
userspace. The server is a single static binary with no dependencies.

**Each direction is split into records, not a byte stream.** Every record is
carried by its own request/response exchange. That costs some efficiency and buys
two properties:

* *Record size is negotiable at runtime.* Some networks silently drop or truncate
  large writes on port 53. Because every record is framed and acknowledged, the
  client can discover the largest size that survives the path, and adapt if
  conditions change later.
* *The transport survives connection rotation.* Session state lives in a 16-byte
  session ID, not in the TCP connection. The client can close and reopen the
  underlying connection between any two records without losing the stream, which
  matters on middleboxes that limit how long a port-53 connection may live or how
  many requests it may carry.

**Stream position is explicit.** Both directions are addressed by absolute byte
offset rather than by message counter. That makes retries idempotent and makes
flow control a matter of reporting a number.

---

## 2. Security model

**DragonTCP does not provide authenticated encryption. It is not a VPN in the
security sense.**

Payloads are **masked**: XORed with a keystream derived from SHA-256 that varies
with session ID, mode, sequence, direction, and block number. The same plaintext
therefore does not produce the same ciphertext twice, and no fixed ASCII markers
appear on the wire.

This defeats trivial pattern matching. It does not defeat anyone who can read the
traffic:

* The mask is derived from the **session ID, which is transmitted in cleartext in
  every request header.** Anyone who sees the header can regenerate the keystream
  and recover the plaintext. This is obfuscation, not confidentiality.
* Record headers — mode, session, sequence, length — are never masked.
* `StatusError` bodies are sent **unmasked**, as plain text.
* There is no integrity check, so payloads can be tampered with undetected.

The optional `--token` is a shared secret compared in constant time. It gates who
may open sessions. It is not a key and does not affect the mask.

**Consequence:** rely on TLS end to end. HTTPS through DragonTCP is protected by
HTTPS, not by DragonTCP. Do not assume anything sent over plain HTTP through this
tunnel is private.

On the server side, the relay refuses private and special-use destinations by
default (§6.5). That restriction is what stops the tunnel being used to reach the
host's own localhost services or cloud metadata endpoints.

---

## 3. Repository layout

```text
core/
  go.mod                              module "dragontcp", Go 1.22, no dependencies
  cmd/dragontcp-client/
    main.go                           local HTTP/HTTPS proxy, CLI flags
    chunk.go                          transport: lanes, probing, adaptation, batching
    chunk_test.go
  cmd/dragontcp-server/
    main.go                           listener, DNS cache, address filtering, CLI flags
    chunk.go                          session manager, buffering, request dispatch
    debug.go                          counters and periodic statistics
    chunk_test.go
  internal/wire/
    protocol.go                       record format and the masking keystream
    protocol_test.go
  internal/protocol/
    protocol.go                       TCP tuning, relay loops, legacy UP/OK framing
    xor_*.go                          word-at-a-time XOR helpers (legacy path)

android/
  AndroidManifest.xml                 package com.dragontcp.client, minSdk 29
  src/com/dragontcp/client/           Java: UI, VpnService, log screen
    MainActivity.java                 settings screen
    DragonService.java                VpnService: spawns the core, owns the TUN
    LogActivity.java                  live log viewer
    AppLog.java                       in-memory log buffer
  src/tech/xvanturing/freeproxy/      Kotlin: userspace TCP/IP stack (Apache 2.0, §14)
    vpn/TunnelEngine.kt               TUN read/write loops, session tables
    vpn/TcpSession.kt                 userspace TCP endpoint
    vpn/UdpSession.kt                 UDP/DNS handling
    vpn/net/                          IP/TCP/UDP headers, checksums, packet builder, DNS
    vpn/proxy/ProxyClient.kt          HTTP CONNECT client
  res/                                icon and theme
  assets/                             license texts shipped inside the APK
  lib/arm64-v8a/libdragontcp_client.so   the Go client, packaged as a native lib
  build_apk.ps1 / build_apk.cmd       Windows build
  build_apk.sh                        Linux/macOS build

build_core.ps1 / build_core.sh        Go builds (Windows / Unix)
build_all.sh                          core + APK in one step (Unix)
bin/                                  built server and client binaries
licenses/                             full Apache 2.0 text
SHA256SUMS                            digests for the built binaries
THIRD_PARTY_NOTICES.md                upstream attribution (a license condition)
```

There is exactly one copy of every source file. A second `core/`, or a nested
`android/android/`, is stale — `build_apk.ps1` guards against this by locating
the repo root as the nearest ancestor holding **both** `core/go.mod` and
`build_core.ps1`.

`core/internal/protocol` still contains the legacy `UP`/`OK` text framing and a
fixed `0xAD` XOR. That package is retained because it also holds the TCP tuning
helpers and relay loops the current transport uses. The legacy framing itself is
unreachable: `dragontcp-client` refuses to start unless `--transport chunk`.

---

## 4. The wire protocol

Implemented in `core/internal/wire/protocol.go`.

### 4.1 Record framing

Client to server — a **request**:

```text
offset  size  field
   0      1   mode
   1     16   session ID
  17      8   sequence          (big-endian uint64)
  25      4   payload length    (big-endian uint32)
  29      n   payload           (masked)
```

Server to client — a **response**:

```text
offset  size  field
   0      1   status
   1      4   body length       (big-endian uint32)
   5      n   body              (masked, with exceptions in §4.3)
```

Headers are **29 bytes** and **5 bytes**. The wire layer rejects payloads above
2 MiB (`MaxPayload`); the transport never exceeds 1 MiB.

A single request may be answered by **several** responses — see `ModeDownload`.

### 4.2 Modes and statuses

| Mode | Value | Meaning |
|---|---|---|
| `ModeProbe` | 0 | Path measurement; no session required |
| `ModeOpen` | 1 | Create a session and connect to the target |
| `ModeUpload` | 2 | Push payload bytes toward the target |
| `ModeDownload` | 3 | Request buffered bytes coming back |
| `ModeClose` | 4 | Tear the session down |

| Status | Value | Meaning |
|---|---|---|
| `StatusOK` | 0 | Success; body may carry a result |
| `StatusError` | 1 | Failure; body is a plaintext message |
| `StatusData` | 2 | Body is stream data |
| `StatusWait` | 3 | Nothing available yet; ask again |
| `StatusEOF` | 4 | Target closed the stream |

### 4.3 Payload masking

```text
seed[0:16]  = session ID
seed[16]    = mode
seed[17:25] = sequence            (big-endian)
seed[25]    = 1 for responses, 0 for requests
seed[26:30] = block counter       (big-endian, increments every 32 bytes)

keystream_block = SHA256(seed)
payload ^= keystream
```

Masking is its own inverse, so one function both encodes and decodes. Because the
sequence field is a **byte offset** (§4.5), consecutive records never reuse a
keystream position, and re-sending the same offset reproduces the same bytes —
which is what makes idempotent retries safe.

Not everything is masked. `WriteResponse` sends the body as-is and is used for
empty `StatusOK`, `StatusWait`, `StatusEOF`, and every `StatusError`.
`WriteMaskedResponse` is used for `StatusData` and for the `OPEN` result. On the
client, `DecodeMaskedResponse` deliberately skips decoding when the status is
`StatusError`, so both sides agree.

### 4.4 Payload layouts

**PROBE** request payload:

```text
offset  size  field
   0      4   magic "DTP2"
   4      1   probe kind
   5      2   token length      (big-endian uint16)
   7      4   value             (big-endian uint32)
  11      t   token
 11+t     …   filler, byte i = (i*31 + 17) & 0xFF
```

| Probe kind | Value | Server behaviour |
|---|---|---|
| `ProbeUpload` | 1 | Replies `OK` if the whole record arrived and is within `--chunk-max`. The *filler* is what is being measured. |
| `ProbeDownload` | 2 | Replies `StatusData` with exactly `value` bytes of the same generated pattern. |
| `ProbeKeepalive` | 3 | Replies `OK`. Used to test whether one connection may carry several requests. |
| `ProbeBatch` | 4 | Replies with up to 16 back-to-back 32-byte `StatusData` records. |

The client verifies download probes byte for byte, so a middlebox that truncates
or rewrites the response fails the probe instead of silently corrupting data.

**OPEN** request payload:

```text
offset  size  field
   0      2   token length
   2      2   host length
   4      2   target port
   6      t   token
 6+t      h   target host (name or literal IP)
```

The response is `StatusOK` with a masked 4-byte body: the server's `--chunk-max`.
The client clamps its own maximum to that value. Repeating `OPEN` for an existing
session is idempotent and returns the same limit.

**UPLOAD** — the sequence field is the byte offset in the upload stream and the
payload is the data. The response is an empty `StatusOK` acknowledgement.

**DOWNLOAD** — the sequence field is the byte offset the client wants next. The
14-byte payload is:

```text
offset  size  field
   0      8   ack offset — everything below this has been consumed
   8      4   maximum bytes per record
  12      2   how many records the client will accept in this batch
```

The server answers with up to `count` `StatusData` records, each masked with the
running offset, terminated early by one `StatusWait` or `StatusEOF`.

**CLOSE** — no payload; the server drops the session and replies `StatusOK`.

### 4.5 Stream offsets and flow control

Three offsets drive the whole protocol:

| Offset | Held by | Meaning |
|---|---|---|
| upload sequence | client | bytes already sent toward the target |
| download sequence | client | next byte the client wants |
| ack offset | client, reported to server | next byte the **application** has not yet read |

The ack offset trails the download offset. It advances only when the consuming
application actually reads bytes out of the client's buffer. The server uses it to
discard acknowledged data; when the client stops reading, the server's buffer
fills, its reader goroutine blocks, and TCP backpressure propagates to the origin
server. There is no separate window mechanism — the ack number is the window.

### 4.6 A session end to end

```text
client                                            server
  │── PROBE  upload   (binary search)  ─────────▶│
  │◀─ OK / error ────────────────────────────────│
  │── PROBE  download (binary search)  ─────────▶│
  │◀─ DATA(pattern) ─────────────────────────────│
  │── PROBE  keepalive ×8 on one connection ────▶│
  │◀─ OK ×8 ─────────────────────────────────────│
  │
  │── OPEN sid=… host=example.com port=443 ─────▶│  dial example.com:443
  │◀─ OK body=chunk_max ─────────────────────────│
  │
  │── UPLOAD sid seq=0 payload=ClientHello ─────▶│  write() to target
  │◀─ OK ────────────────────────────────────────│
  │── DOWNLOAD sid seq=0 ack=0 limit=1400 cnt=4 ▶│
  │◀─ DATA(1400) DATA(1400) DATA(900) WAIT ──────│
  │── UPLOAD sid seq=517 payload=… ─────────────▶│
  │◀─ OK ────────────────────────────────────────│
  │── DOWNLOAD sid seq=3700 ack=3700 … ─────────▶│
  │◀─ EOF ───────────────────────────────────────│
  │── CLOSE sid ────────────────────────────────▶│
  │◀─ OK ────────────────────────────────────────│
```

---

## 5. The Go client

Source: `core/cmd/dragontcp-client/`.

### 5.1 Local proxy front end

`main.go` listens on `127.0.0.1:8080` and speaks ordinary HTTP proxy protocol.

**`CONNECT host:port`** opens a tunnel, replies `200 Connection Established`, and
relays bytes both ways. This is the path used for HTTPS and, on Android, for
everything.

**Plain `GET http://…`** is rewritten to origin form: the request line is
rebuilt, `Connection`, `Proxy-Connection`, and `Proxy-Authorization` are stripped,
a `Host` header is synthesised if absent, and `Connection: close` is appended.

Request headers are read until `\r\n\r\n`, with a 128 KiB ceiling. The local
connection carries a 15-second deadline during the handshake, cleared once
relaying starts. Accepts are bounded by `--max-connections` through a slot
channel; over the limit the client returns `503`.

Relaying uses `io.Copy` in both directions and waits for **both** to finish, so
TCP half-close is preserved and slow or large responses are not truncated. On
Linux this lets the kernel use `splice`.

### 5.2 `chunkConn`

`openChunkTunnel` returns a `chunkConn` implementing `net.Conn`, so the proxy
front end never knows it is talking to a record protocol. It holds:

* `upOffset` — bytes sent, used as the upload sequence,
* `downloadOffset` — next byte to request,
* `consumedOffset` — next unread byte, sent as `ack`,
* `readBuf` — received but not yet delivered to the reader,
* two `requestLane`s of its own, one for uploads and one for downloads, so a
  blocking download poll never delays an upload.

`Write` slices the caller's buffer into records of the current upload size, each
acknowledged before the next is sent. `Read` refills `readBuf` via
`fillReadBuffer`, which issues batched download requests. `Close` sends
`ModeClose` over the upload lane, then closes both lanes.

### 5.3 Request lanes and connection reuse

A `requestLane` owns at most one physical TCP connection and serialises requests
onto it under a mutex. Each tunnel has two of them (§5.7). Any I/O error discards
the connection; the next request redials. Sockets get `TCP_NODELAY`, 30-second
keepalives, and optionally explicit buffer sizes via `--tcp-buffer`.

| `--chunk-reconnect-every` | Behaviour |
|---|---|
| `0` | Persistent — one connection for the life of the lane |
| `1` (default) | Auto — persistent if the path probe showed reuse works, otherwise one logical request per connection. Resolved silently, since it runs once per flow |
| `N ≥ 2` | Rotate — close and redial after N logical requests |

### 5.4 Path probing

Before the first real connection, `getPathProfile` measures the path once and
caches the result for **30 minutes**, keyed by server address, token, and size
bounds.

Upload and download are probed **independently and concurrently**, each by binary
search over a fixed ladder:

```text
32, 64, 128, 256, 512, 1024, 1200, 1280, 1320, 1350, 1360, 1380, 1400,
1450, 1600, 2048, 3205, 4096, 8192, 16384, 32768, 65536, 98304,
131072, 262144, 524288, 786432, 1048576
```

The ladder is filtered to `[--chunk-min, --chunk-max]`, with the configured bounds
added if missing. Binary search costs roughly five probes instead of twenty-eight,
and avoids having to *fail* at every size on the way down during real traffic.

A third probe sends eight keepalives on one connection to decide whether request
reuse survives. Each probe uses a fresh random session ID with a timeout capped at
2.5 s. If the search does not finish within 20 s, the client falls back to
32 768 up / 1 350 down.

The outcome is logged once:

```text
path probe: upload=32768 download=1400 persistent=true
```

### 5.5 Adaptive record sizing

`adaptiveSizer` keeps one instance per direction plus two landmarks: `good`, the
largest size known to work, and `bad`, the smallest known to fail.

**On failure** at the current size:

* record `bad = min(bad, attempted)`,
* drop to `good` if a smaller known-good size exists, otherwise halve,
* clamp to `--chunk-min` and force a strict decrease.

**On success** at the current size, after `--chunk-grow-after` consecutive
successes (default 16):

* if `bad` is known and more than one step above, move **halfway toward it** — a
  binary search upward rather than a blind jump,
* otherwise clear the stale `bad` landmark and grow by `max(current/4, 32)`,
* clamp to the maximum.

When `bad - good ≤ 64` the required success count is multiplied by eight: once the
working size is tightly bracketed the controller stops probing the ceiling and
settles.

```text
adaptive upload chunk: 32768 -> 16384 after transport failure
adaptive download chunk: 1400 -> 700 after transport failure
adaptive upload chunk: 700 -> 1050 after stable success
```

### 5.6 Download batching

One download request can return many records. **This is a transport setting, not
a thread count** — nothing here creates threads. It controls how many records the
server may stream back in reply to a single request.

Two bounds define it:

| Flag | Default | Meaning |
|---|---|---|
| `--chunk-concurrency` | `1` | Maximum records per request (1–256) |
| `--chunk-concurrency-min` | `1` | Minimum records per request (1–256) |

Batching matters most on restricted paths. If the safe record size is 32 bytes,
one TCP/53 request can still return many 32-byte records instead of needing a
fresh request for every 32 useful bytes.

**Two modes**, decided entirely by whether the bounds are equal:

* **Pinned** (`min == max`). The depth never changes: it does not grow, it does
  not halve on failure, and it is not reduced by the 1 MiB batch cap. Use this
  when a path only works at one specific number of records — the adaptive
  controller stays completely out of the way.
* **Adaptive** (`min < max`). The depth starts at the ceiling, **halves** on
  transport failure but never below `min`, and **grows by one** after successful
  data responses until it reaches the ceiling again.

`1..1` is the pinned case at one record, which is the default.

```text
adaptive download batch: 64 -> 32 after transport failure
```

Outside pinned mode, each batch is additionally capped to carry roughly 1 MiB of
useful data:

```go
count = min(pipeline, maxPipeline, (1 MiB) / chunkSize)
count = max(count, minPipeline)   // the floor always wins
```

The server independently clamps `count` to 256 and `limit` to its own
`--chunk-max`, so a client can never demand more than the server allows.

### 5.7 Connection model

Each proxied socket gets its own tunnel, and each tunnel dials **two** TCP
connections to the server: one upload lane and one download lane. `OPEN` and
`CLOSE` ride the upload lane rather than dialling their own connections.

So a device browsing normally holds roughly `2 × active flows` connections to
port 53, plus churn as flows come and go. That is the transport behaving as a
proxy, not as a single multiplexed link.

**Why it is not one connection.** A response header is only `status + length` —
it carries no session or request ID. Responses can therefore only be matched to
requests by **arrival order**, which means a connection must finish one full
exchange before another session may use it. Since a download is a long poll that
can block for `--chunk-poll-wait`, sharing one connection across sessions lets
idle pollers starve real traffic. Per-tunnel lanes are a requirement of the
current wire format, not an oversight.

Making the client hold a single connection would require adding a session ID to
the response header, demultiplexing responses asynchronously on the client, and
handling requests concurrently per connection on the server — a wire-format
change affecting both ends.

`--chunk-pollers` is accepted for compatibility and validated to 1–128, but the
transport uses one download worker per tunnel and never reads it.

### 5.8 Failure escalation

On a download failure the client escalates in a fixed order:

1. **Shrink the batch** — halve it, never below the floor.
2. **Shrink the record size** — only once the batch is already at its floor.
3. **Give up** — after eight consecutive failures at the minimum record size,
   return an error rather than spinning forever.

Uploads have no batch dimension, so they go straight to steps 2 and 3. A 30 ms
pause separates retries. `StatusWait` responses are followed by a
`--chunk-poll-delay` pause (default 2 ms) instead of being treated as failures.

---

## 6. The Go server

Source: `core/cmd/dragontcp-server/`.

### 6.1 Connection handling

The server listens on `0.0.0.0:53` by default, bounded by `--max-connections`
through a slot channel. Each connection runs a loop: read one request with a
30-second deadline, dispatch it, repeat. Because session state is keyed by session
ID rather than by connection, requests for one logical stream may arrive over many
connections in whatever pattern the client chooses.

### 6.2 Session state and buffering

Each `OPEN` creates a `streamSession` holding the real TCP connection to the
target plus a download buffer:

* `buf` — bytes received from the target but not yet acknowledged,
* `base` — the absolute stream offset of `buf[0]`,
* a dedicated goroutine reading the target in 64 KiB chunks.

That goroutine **blocks when the buffer is full**, which is the entire flow
control story described in §4.5. Buffer size is `--chunk-buffered × 65536`,
clamped to 1 MiB…64 MiB (default 256 → 16 MiB per session).

Acknowledgement drops bytes off the front and advances `base`. The buffer is
compacted when its capacity exceeds four times its length and is over 1 MiB, so
long-lived sessions do not hold onto peak allocations.

Sessions are stored in a map keyed by the raw 16 session bytes. Creating a session
that already exists closes the newcomer and keeps the original.

### 6.3 Serving downloads

`readAt(offset, limit, wait)` requires `offset` to lie within
`[base, base+len(buf)]`. An offset below `base` is an error — those bytes were
acknowledged and discarded. An offset beyond the buffered end is also an error.

Two behaviours matter:

* **Long poll.** The *first* record of a batch waits up to `--chunk-poll-wait`
  (default 200 ms) for data to arrive. Later records in the same batch do not
  wait: the batch drains what is buffered and then returns `StatusWait`. This
  stops batches stalling on partially-filled pipelines.
* **Coalescing.** If fewer than `limit` bytes are available, the server waits up
  to 2 ms more for the target to produce more. Without this, a 1-byte read from
  the origin would become a permanent 1-byte tunnel record and per-record overhead
  would dominate.

### 6.4 Serving uploads

Uploads must arrive in exact order: the offset must equal the session's
`expectedUp`. Two cases are special:

* an offset entirely **below** `expectedUp` is an idempotent retry after a lost
  acknowledgement and silently succeeds,
* a partially overlapping retry is rejected as an error.

This is what makes it safe for the client to resend a record whose response was
lost when a connection died mid-request.

### 6.5 Target resolution and address filtering

`dialTarget` resolves through a bounded DNS cache (`--dns-cache-ttl`, default
30 s; `--dns-cache-size`, default 4096). When the cache is full it resets
wholesale rather than evicting entry by entry — cheap, and adequate for a hot
cache. Literal IPs bypass resolution.

Each candidate address is checked before dialling. Rejected by default:
unspecified, multicast, non-global-unicast, private, loopback, link-local, and
these special-use prefixes:

```text
0.0.0.0/8        100.64.0.0/10    192.0.0.0/24     192.0.2.0/24
198.18.0.0/15    198.51.100.0/24  203.0.113.0/24   240.0.0.0/4
2001:db8::/32
```

`--allow-private` disables the whole check. Leave it off on a public server.

If every resolved address is blocked, the error names them. Dial timeout is 10 s,
with 30-second keepalives on the resulting connection.

### 6.6 Lifecycle and diagnostics

A sweep every 30 s closes sessions idle longer than `--chunk-session-timeout`
(default 2 minutes), and any session already marked closed.

`--debug` logs accepts, session opens, and errors to stderr.
`--debug-stats-interval` prints counters: bytes up and down, push records, pull
requests, data and wait records, sessions opened and closed, active sessions.
`--debug-chunks` logs every record and is very verbose.

---

## 7. The Android app

Package `com.dragontcp.client`, `minSdk 29`, `targetSdk 29`, arm64 only.

### 7.1 Components

| Component | Language | Role |
|---|---|---|
| `MainActivity` | Java | Settings screen; validates and persists configuration |
| `DragonService` | Java | `VpnService`: spawns the Go core, owns the TUN, runs the foreground notification |
| `LogActivity` / `AppLog` | Java | Live log viewer over a 600-line in-memory ring |
| `TunnelEngine` | Kotlin | TUN read/write loops, session tables, housekeeping |
| `TcpSession` | Kotlin | Userspace TCP endpoint, one per 4-tuple |
| `UdpSession` | Kotlin | UDP handling; in practice DNS only |
| `net/*` | Kotlin | Header parsing, checksums, packet construction, DNS parsing |
| `ProxyClient` | Kotlin | HTTP CONNECT client against the local Go proxy |

### 7.2 Process model

The APK ships the Go client at `lib/arm64-v8a/libdragontcp_client.so`. Despite the
name it is not a shared library — it is a statically linked Go executable. The
`lib*.so` naming plus `extractNativeLibs="true"` makes Android unpack it into
`nativeLibraryDir` with the executable bit set, which is the standard way to ship
a helper binary in an APK.

`DragonService` launches it with `ProcessBuilder`, merges stderr into stdout, and
reads the output on a background thread. Only interesting lines reach the UI log:
those beginning with `adaptive ` or `path probe:`, and anything containing `error`
or `failed`. A watchdog thread waits on the process; if the core exits while the
tunnel is supposed to be up, the whole VPN is torn down.

### 7.3 Startup and shutdown

Startup:

1. `MainActivity` validates the form and saves it to `SharedPreferences`.
2. `VpnService.prepare()` shows the system consent dialog if needed.
3. `DragonService` starts in the foreground with a notification carrying a STOP
   action.
4. The Go core is spawned with flags built from the saved settings.
5. The service polls `127.0.0.1:8080` for up to 10 s (150 ms connect timeout,
   100 ms between attempts) until the proxy accepts.
6. The TUN interface is established.
7. `TunnelEngine` starts; state broadcasts flip to `CONNECTED`.

A service-side guard makes a duplicate CONNECT intent a no-op, so a double tap
cannot tear down a healthy tunnel and restart it.

Shutdown runs in a fixed order: stop the engine, close the TUN descriptor, then
`destroy()` the core process, waiting 1200 ms before `destroyForcibly()` and a
further 800 ms. `onRevoke()` (permission withdrawn by the system) and `onDestroy()`
both route through the same path.

### 7.4 The TUN interface

```java
setSession("DragonTCP Lite")
setMtu(1400)
addAddress("10.77.0.2", 32)                 addRoute("0.0.0.0", 0)
addAddress("fd77:6472:6167:6f6e::2", 128)   addRoute("::", 0)
addDnsServer("1.1.1.1")
addDisallowedApplication(<self>)
setBlocking(true)   setMetered(false)
```

Two decisions matter:

* **IPv6 is captured, then dropped.** The userspace stack is IPv4-only. Routing
  `::/0` into the tunnel and discarding it prevents apps from bypassing the tunnel
  over IPv6. It is a blackhole by design.
* **The app excludes itself** from the VPN, and every upstream socket is
  additionally passed through `VpnService.protect()`. Both are needed so the Go
  core's connection to the server is not routed back into the TUN it serves.

### 7.5 Packet dispatch

`TunnelEngine` runs one reader thread and one writer thread on the TUN file
descriptor, plus a cached thread pool exposed to coroutines for per-session
blocking I/O.

The reader parses IPv4 only. `Ipv4Header.parse` rejects anything that is not
version 4, has an inconsistent length, or is a **fragment** (MF set or a non-zero
fragment offset) — the stack does no reassembly, and since the MTU is chosen
locally, normal traffic never fragments. Non-IPv4, ICMP, and malformed packets are
dropped silently.

* **TCP** goes to a `TcpSession` keyed by the 4-tuple. New flows may only be
  created by a SYN; anything else receives an RST so apps fail fast instead of
  hanging. `putIfAbsent` prevents a retransmitted SYN from creating two sessions.
* **UDP** goes to a `UdpSession`, created on first packet.

Limits: 512 concurrent TCP sessions, 256 UDP. The TUN write queue holds 1024
packets and **drops** on overflow rather than blocking session threads — if the
kernel side cannot keep up, dropping is the correct behaviour for a link layer.
Housekeeping every 5 s expires idle sessions: TCP 300 s, DNS 20 s, other UDP 120 s.

### 7.6 The userspace TCP endpoint

`TcpSession` acts as the *server* toward the phone's own kernel: it answers SYN
with SYN-ACK, acknowledges data, and sends FIN or RST. The real traffic travels
through a socket to the local proxy.

The key simplification: packets written to the TUN go to the local kernel over a
lossless path, so **no congestion control is required**. Respecting the peer's
advertised receive window is sufficient; retransmission exists only as a backstop.

| Constant | Value |
|---|---|
| MSS | `MTU − 40`, clamped to 536…1460 (1360 at MTU 1400) |
| Receive window | 65535 |
| Max in flight | 65535 |
| Upstream queue | 64 chunks |
| Retransmit timeout | 400 ms |
| Window poll interval | 100 ms |

**Connection setup.** On SYN the session records the peer's sequence and window,
then asynchronously opens the tunnel. If that fails it sends an RST immediately so
the app sees "connection refused" instead of waiting for a timeout. On success it
sends SYN-ACK carrying an MSS option, so the kernel never hands down a segment
larger than the tunnel MTU. The initial sequence number is random.

**Inbound data.** Only in-order segments are accepted (`sequence == receiveNext`);
anything else is answered with a bare ACK. Accepted payloads are pushed to a
bounded channel. If that channel is full, `receiveNext` is *not* advanced and the
advertised window shrinks — eventually to zero — which pauses the application.
When the upstream pump drains a chunk and the window crosses back above one MSS,
a single window-update ACK is sent, avoiding a redundant ACK per chunk.

**Outbound data.** The downstream pump reads MSS-sized buffers from the proxy
socket and writes them back as IPv4+TCP packets with PSH|ACK, throttled by
`awaitSendWindow`, which blocks until in-flight bytes plus the new chunk fit
inside `min(max(peerWindow, mss), 65535)`. Every segment is copied into a
retransmit queue; if no ACK arrives for 400 ms, the queue head is retransmitted.
Acknowledged segments are released from the front of the queue, with sequence
comparisons done as 32-bit signed differences so wraparound is handled correctly.

**Teardown.** A FIN from the app closes the upstream channel; once the pump
drains it, `shutdownOutput()` tells the proxy the request is complete. When the
proxy side reaches EOF the session sends FIN; on an exception while established it
sends RST. `sendReset` uses its own buffer because it may run while another thread
holds the shared output buffer.

### 7.7 UDP and DNS

With an HTTP CONNECT upstream, general UDP cannot be carried. `UdpSession`
therefore handles **DNS only**; every other UDP flow is dropped, which makes QUIC
fail and pushes apps back to TCP.

DNS queries are converted to **DNS-over-TCP** (RFC 7766: a 2-byte big-endian
length prefix followed by the message) and sent to **1.1.1.1:53** through the
tunnel. One tunnel is opened per query with a 10-second timeout, and the session
ends after the answer is written back to the TUN as a synthesised UDP datagram.

Responses are also parsed to populate `HostRegistry`, a 512-entry access-ordered
LRU mapping IP → hostname. It exists so diagnostics can say `github.com:443`
rather than `140.82.121.4:443`.

The stack retains hooks for direct (non-tunnelled) DNS, SOCKS5 UDP association,
DNS blocking, and per-app attribution. In this build `AppResolver`, `DnsBlocker`,
and `TunnelLog` are deliberate no-op stubs that preserve the upstream API.

### 7.8 Packet construction

`PacketBuilder` writes IPv4 packets into a caller-supplied buffer and returns the
length, allocating nothing on the hot path. Headers carry TTL 64, the Don't
Fragment flag, and a monotonically increasing identification field. Checksums
follow RFC 1071, with the TCP/UDP pseudo-header sum folded in; a computed UDP
checksum of zero is written as `0xFFFF` per RFC 768.

### 7.9 Settings

| UI field | Default | Flag passed to the core |
|---|---|---|
| Server | — | `--server-host` |
| Port | 53 | `--server-port` |
| Token | empty | `--token` (omitted entirely when blank) |
| Max chunk | 1048576 | `--chunk-max` **and** `--chunk-start` |
| Min chunk | 32 | `--chunk-min` |
| Batch max | 1 | `--chunk-concurrency` |
| Batch min | 1 | `--chunk-concurrency-min` |
| Reconnect every | 1 (auto) | `--chunk-reconnect-every` |
| Timeout (s) | 2 | `--chunk-timeout` |

Fixed by the service: `--listen-host 127.0.0.1`, `--listen-port 8080`,
`--transport chunk`, `--chunk-grow-after 16`, `--chunk-adapt-log=true`.

Settings are laid out in four cards: CONNECTION, RECORD SIZE, DOWNLOAD BATCH,
and ADVANCED. The batch card carries a live hint that restates the current setting in
words as you type, so the mode is never ambiguous:

```text
Batch max 1   Batch min 1    →  Fixed: 1 record per request, never adapts.
Batch max 5   Batch min 5    →  Pinned: exactly 5 records per request, never adapts.
Batch max 16  Batch min 1    →  Adaptive: starts at 16, falls back toward 1
                                on errors, recovers to 16.
Batch max 4   Batch min 9    →  Batch min must not be greater than batch max.
```

The service independently clamps both values into 1–256 and forces `min ≤ max`
before spawning the core, and records the resulting mode in the log:

```text
Download batch: pinned at 5 records per request (never adapts)
```

Validation ranges: port 1–65535, max chunk 32–1048576, min chunk 32–max chunk,
batch values 1–256 with `min ≤ max`, reconnect 0–1000000,
timeout 1–120.

### 7.10 Logs

`AppLog` keeps the last 600 lines in memory and pushes them live to any
registered listener. `LogActivity` renders them in a selectable monospace view
with BACK and CLEAR actions and subscribes for live updates while visible. Nothing
is written to disk.

---

## 8. Building

### 8.1 Windows

No Gradle and no Android Studio required; `android\build_apk.ps1` drives the SDK
command-line tools directly.

```powershell
cd android
.\build_apk.ps1
```

Or double-click `android\build_apk.cmd`. The result is
`android\build\DragonTCP-Hybrid-arm64.apk`, signed with a debug keystore
generated on first run.

| Component | How it is found | Required? |
|---|---|---|
| **Android SDK** | `ANDROID_SDK_ROOT`, `ANDROID_HOME`, `%LOCALAPPDATA%\Android\Sdk`, `C:\Android\Sdk`, or `-SdkRoot` | Yes |
| **build-tools** | Newest version having `aapt.exe`, `d8.bat`, `apksigner.bat`, `zipalign.exe`; or `-BuildTools` | Yes |
| **Platform** | `android-35` if present, else newest with an `android.jar`; or `-Platform` | Yes |
| **JDK 17+** | `JAVA_HOME`, then `javac` on `PATH` (only if `jar.exe` sits beside it), then `C:\Program Files\Java\*`; or `-JavaHome` | Yes — a JRE is not enough |
| **Kotlin** | `KOTLIN_HOME`, else `android\.tools\kotlinc-<version>`; downloaded automatically if absent | Auto |
| **Go** | `PATH`, or `-GoBin` on `build_core.ps1` | Only to rebuild the `.so` |

Options:

```powershell
.\build_apk.ps1 -BuildCore                    # rebuild the Go .so first
.\build_apk.ps1 -BuildTools 35.0.0 -Platform android-35
.\build_apk.ps1 -JavaHome 'C:\Program Files\Java\jdk-21.0.10'
.\build_apk.ps1 -KotlinHome C:\kotlinc -NoDownload
.\build_apk.ps1 -KotlinVersion 2.2.0
.\build_apk.ps1 -RepoRoot C:\path\to\DragonTCP
.\build_apk.ps1 -Keystore C:\keys\release.jks -KsPass … -KeyAlias … -KeyPass …
```

Go binaries alone:

```powershell
.\build_core.ps1                # android .so + linux client + linux amd64/arm64 servers
.\build_core.ps1 -ClientOnly    # just the .so
.\build_core.ps1 -AndroidLibDir <dir>
```

### 8.2 Linux and macOS

```bash
./build_core.sh
./android/build_apk.sh
./build_all.sh       # both
```

`build_apk.sh` expects `ANDROID_SDK_ROOT` (or `ANDROID_HOME`) and a `KOTLIN_HOME`
pointing at a Kotlin distribution bundling `lib/kotlinx-coroutines-core-jvm.jar`;
it defaults to `~/.sdkman/candidates/kotlin/current`. Unlike the PowerShell script
it downloads nothing and does not build the Go core for you.

`build_core.sh` does not build the Linux **client**; `build_core.ps1` does. Build
it by hand if you need it.

Go builds by hand:

```bash
cd core
go test ./...

GOOS=android GOARCH=arm64 CGO_ENABLED=0 \
  go build -trimpath -ldflags='-s -w' \
  -o ../android/lib/arm64-v8a/libdragontcp_client.so ./cmd/dragontcp-client

GOOS=linux GOARCH=amd64 CGO_ENABLED=0 \
  go build -trimpath -ldflags='-s -w' \
  -o ../bin/dragontcp-hybrid-server-linux-amd64 ./cmd/dragontcp-server
```

CGO is off for every target, so no NDK and no C toolchain are needed.

### 8.3 What the build does

1. **Resolve the toolchain and repo root**, and print what was chosen.
2. **Build the native core** if the `.so` is missing or `-BuildCore` was passed,
   by calling `<repo>\build_core.ps1 -ClientOnly -AndroidLibDir <app>\lib\arm64-v8a`.
3. **`aapt package`** — compile `res/`, pack `assets/`, bind the manifest against
   `android.jar`, producing `resources.ap_`.
4. **Kotlin** — compile every `.kt` under `src\` to `build\kclasses`, JVM target
   1.8, against `android.jar` + coroutines + stdlib.
5. **Java** — compile every `.java` under `src\` to `build\jclasses` with
   `--release 8`, against `android.jar` + the Kotlin output + stdlib.
6. **`jar`** both class trees, then **`d8`** them with `kotlin-stdlib`,
   `kotlin-stdlib-jdk7/8`, and `kotlinx-coroutines-core-jvm` into `classes.dex` at
   `--min-api 29`.
7. **Package** — copy `resources.ap_` to the APK and add `classes*.dex` plus the
   whole `lib\` tree using .NET's `ZipArchive`.
8. **`zipalign -p -f 4`**, then **`apksigner sign`**, then
   **`apksigner verify --verbose`**.

### 8.4 Windows implementation notes

* **The Kotlin compiler is invoked as
  `java -cp kotlin-compiler.jar org.jetbrains.kotlin.cli.jvm.K2JVMCompiler`, not
  through `kotlinc.bat`.** `cmd.exe` treats `;` as an argument separator, so
  `-classpath a.jar;b.jar` handed to a batch file is split into two arguments and
  the second jar is misread as a source file. Calling `java.exe` directly bypasses
  the batch tokenizer.
* **`d8.bat` and `apksigner.bat` remain batch files.** Their arguments contain no
  semicolons, but a project path containing spaces or semicolons could hit the
  same class of problem.
* **Packaging uses .NET `ZipArchive`** because Windows has no `zip` command.
* **`zipalign` runs before signing**, which is the required ordering.
* **Native-tool stderr is not treated as failure.** The JVM emits a
  `sun.misc.Unsafe` warning on recent JDKs and `apksigner` emits a native-access
  warning; under `$ErrorActionPreference = 'Stop'` with a merged output stream
  those would abort the build, so `Invoke-Tool` judges success by exit code alone.

A `.gitignore` in the app directory keeps `build/`, `.tools/`, and the debug
keystore out of version control.

---

## 9. Running

### 9.1 Server

```bash
sudo ./dragontcp-hybrid-server-linux-amd64 --port 53 --chunk-max 1048576
```

With a token:

```bash
sudo ./dragontcp-hybrid-server-linux-amd64 \
  --token 'YOUR_SECRET' --port 53 --chunk-max 1048576
```

With diagnostics:

```bash
sudo ./dragontcp-hybrid-server-linux-amd64 \
  --port 53 --chunk-max 1048576 --debug --debug-stats-interval 10s
```

`sudo` is needed only because port 53 is privileged. If `systemd-resolved` or
`dnsmasq` already owns port 53, free it or choose another port. No TUN device, NAT,
or firewall rules are required.

### 9.2 Client CLI

```bash
./dragontcp-hybrid-client-linux-amd64 \
  --server-host YOUR_SERVER_IP --server-port 53 \
  --listen-port 8080 --chunk-max 1048576
```

Then point anything at `http://127.0.0.1:8080` as an HTTP proxy. The startup line
summarises the active configuration:

```text
adaptive_chunk=true start=1048576 min=32 max=1048576 grow_after=16 pollers=1 \
batch=5-5(pinned) reconnect_every=0 timeout=2s
```

### 9.3 Android

Install the APK, enter the server address, grant the VPN prompt, connect.
A reasonable starting point:

```text
Server:          YOUR_SERVER_IP
Port:            53
Token:           (match the server, or leave blank)
Max chunk:       1048576
Min chunk:       32
Batch max:       1
Batch min:       1
Pollers:         1
Reconnect every: 1
Timeout (s):     2
```

Use **OPEN LOGS** to watch the path probe and any adaptation.

---

## 10. Configuration reference

### 10.1 Server flags

| Flag | Default | Meaning |
|---|---|---|
| `--host` | `0.0.0.0` | Listen address |
| `--port` | `53` | Listen port |
| `--token` | empty | Optional shared secret |
| `--max-connections` | `20000` | Concurrent TCP connections |
| `--allow-private` | `false` | Allow private/loopback targets — keep off in public |
| `--dns-cache-ttl` | `30s` | Resolver cache lifetime |
| `--dns-cache-size` | `4096` | Cached hostnames |
| `--tcp-buffer` | `0` | Explicit socket buffers; 0 = OS autotuning |
| `--chunk-max` | `1048576` | Largest record accepted (32 B – 1 MiB) |
| `--chunk-buffered` | `256` | Per-session buffer in 64 KiB units (≈16 MiB) |
| `--chunk-poll-wait` | `200ms` | Long-poll wait for a batch's first record |
| `--chunk-session-timeout` | `2m` | Idle session reaping |
| `--debug` | `false` | Session/connect/error logs plus periodic stats |
| `--debug-chunks` | `false` | Log every record — very verbose |
| `--debug-stats-interval` | `5s` | Statistics period; 0 disables |

### 10.2 Client flags

| Flag | Default | Meaning |
|---|---|---|
| `--listen-host` / `--listen-port` | `127.0.0.1` / `8080` | Local proxy bind |
| `--server-host` / `--server-port` | — / `53` | Remote server (host required) |
| `--token` | empty | Shared secret |
| `--transport` | `chunk` | Must be `chunk` |
| `--max-connections` | `20000` | Concurrent proxied connections |
| `--tcp-buffer` | `0` | Explicit socket buffers |
| `--chunk-start` | `1048576` | Initial record size (the probe overrides it) |
| `--chunk-min` | `32` | Record size floor |
| `--chunk-max` | `1048576` | Record size ceiling, further clamped by the server |
| `--chunk-adaptive` | `true` | Enable runtime record resizing |
| `--chunk-grow-after` | `16` | Successes before growing |
| `--chunk-adapt-log` | `true` | Print size and batch changes |
| `--chunk-size` | `0` | Legacy: pins start/min/max and disables adaptation |
| `--chunk-concurrency` | `1` | Download batch ceiling, 1–256 |
| `--chunk-concurrency-min` | `1` | Download batch floor, 1–256; equal to the ceiling pins the depth |
| `--chunk-reconnect-every` | `0` | 0 persistent, 1 auto, N rotate |
| `--chunk-poll-delay` | `2ms` | Pause after an empty poll |
| `--chunk-timeout` | `2s` | Per-record transaction timeout |
| `--chunk-pollers` | `1` | Accepted for compatibility; validated 1–128 but unused |

The `concurrency` flag names are historical. They control the download **batch
depth** described in §5.6, not any form of threading.

### 10.3 Fixed timings

| Where | Value | What |
|---|---|---|
| Client | 15 s | Local-connection handshake deadline |
| Client | 10 s | Dial timeout to the server |
| Client | 2.5 s | Per-probe timeout cap |
| Client | 20 s | Whole-probe timeout per direction |
| Client | 30 min | Path profile cache lifetime |
| Client | 30 ms | Pause between failed record retries |
| Server | 30 s | Per-request read deadline |
| Server | 2 ms | Small-read coalescing window |
| Server | 30 s | Session sweep interval |
| Android | 10 s | Wait for the local proxy to accept |
| Android | 1200 + 800 ms | Graceful then forced core shutdown |
| Android | 400 ms | TCP retransmit timeout |
| Android | 5 s | Session housekeeping interval |

---

## 11. Tuning

Start with the defaults. Path probing already picks sensible sizes, and most
manual tuning makes things worse.

* **Throughput feels capped.** Raise `Batch max` to 4–16 and leave `Batch min` at
  1. More records per request is the main lever when latency to the server is
  high, because each round trip returns more data. If the log repeatedly shows
  `adaptive download batch: N -> N/2`, the path cannot sustain that depth.
* **Streams die mid-transfer, or nothing loads at all.** Set `Reconnect every`
  to `1` (auto). `0` forces persistent connections, and many networks silently
  kill long-lived port-53 connections; auto probes first and falls back to one
  logical request per connection when persistence does not survive. This is the
  single most important setting on a restrictive path.
* **Logs show the same transition many times over (`128 -> 64` repeatedly).**
  Each tunnel adapts independently, so a burst of flows produces a burst of
  identical lines. The app collapses consecutive duplicates into a counted line;
  the underlying behaviour is normal.
* **The batch keeps collapsing to 1 and throughput dies with it.** Some paths only
  deliver correctly at one specific number of records. Try `Batch max = Batch min
  = N` for a few values of N and leave it pinned at whichever works. Pinned mode
  never halves and is never trimmed by the 1 MiB batch cap.
* **You want a floor but still want headroom.** Set `Batch min` to the smallest
  depth the path tolerates and `Batch max` higher; adaptation then works inside
  that window instead of falling all the way to 1.
* **Frequent `after transport failure` lines.** The network is dropping large
  records. Lower `Max chunk` to a size the probe already found safe — 1400 is
  common — so the client stops rediscovering the limit.
* **Connection dies periodically, then recovers.** A middlebox is capping requests
  per connection. Set `Reconnect every` to 8–32.
* **High-latency, high-bandwidth link.** Leave `--tcp-buffer` at 0 first. Only for
  a small number of high-BDP connections is `1048576` or `4194304` worth trying;
  across many connections it costs memory for nothing.
* **Server memory.** Each session can hold `--chunk-buffered × 64 KiB` (default
  16 MiB). Lower it when running many concurrent sessions.

---

## 12. Troubleshooting

### 12.1 Build

| Symptom | Cause and fix |
|---|---|
| `Android SDK not found` | Set `ANDROID_SDK_ROOT` or pass `-SdkRoot`. |
| `No usable build-tools found` | Install build-tools; `aapt`, `d8`, `apksigner`, and `zipalign` must all be present in one version. |
| `Missing …\jar.exe (a JRE is not enough)` | You have a JRE or the `javapath` shim. Install a JDK and set `JAVA_HOME`. |
| `Kotlin download failed` | No network, or a proxy. Download `kotlin-compiler-<ver>.zip` manually, extract it, pass `-KotlinHome <dir>\kotlinc`. |
| `source entry is not a Kotlin file: …jar` | The compiler is being called through `kotlinc.bat`. See §8.4. |
| `libdragontcp_client.so is missing` | Install Go and run `.\build_core.ps1 -ClientOnly`, or pass `-BuildCore`. |
| `…but no core\go.mod was found above` | Run the script from the app directory, or pass `-RepoRoot`. |

### 12.2 Runtime

| Symptom | Cause and fix |
|---|---|
| `CONNECT failed: Server is required` | Empty server field. |
| `Local proxy did not start` | The core died within 10 s. Check the logs; usually a bad flag or an unusable port. |
| `DragonTCP core exited: N` | The core died while connected; the tunnel is torn down deliberately. |
| `authentication failed` | Token mismatch between app and server. |
| `target resolves only to blocked addresses` | The destination is private or special-use. Intentional; see §6.5. |
| `download offset N was already acknowledged` | Client and server disagree on stream position, almost always a stale session after a restart. Reconnect. |
| `upload gap: got N expected M` | The same, in the upload direction. Reconnect. |
| `server maximum chunk N is below client minimum M` | Raise the server's `--chunk-max` or lower the client's `Min chunk`. |
| `download failed at minimum chunk` | Eight consecutive failures at the smallest record size — the path is not passing traffic at all. |
| DNS works, QUIC/UDP apps do not | By design: only DNS is carried over UDP (§7.7). |
| No IPv6 anywhere | By design: IPv6 is captured and blackholed (§7.4). |
| Connections hang instead of failing fast | Expected only for non-SYN packets to unknown flows, which receive an RST. Anything else warrants the logs. |

---

## 13. Testing

```bash
cd core && go test ./...
```

Coverage:

* the masking round-trip, and that different sequences produce different wire
  bytes,
* the adaptive record sizer recovering from the minimum rather than latching
  there,
* `reconnectEvery == 0` meaning persistent,
* a pinned batch never growing or shrinking across repeated failures,
* a pinned batch surviving the ~1 MiB per-batch cap while an unpinned one is still
  trimmed by it,
* an adaptive batch halving down to its floor and growing back to its ceiling,
* `1..1` being treated as pinned.

Beyond unit tests, the transport has been exercised with an 8 MiB HTTP download
verified by SHA-256, an HTTPS `CONNECT` download verified byte for byte,
persistent and forced-rotation connection modes, and a server restricted to
1400-byte records where probing selected 1400 automatically and the download still
completed correctly.

`SHA256SUMS` records digests for the built binaries; the digest for
`android/lib/arm64-v8a/libdragontcp_client.so` matches the copy packaged inside
the APK.

---

## 14. Licensing

The Android userspace TCP/IP stack under
`android/src/tech/xvanturing/freeproxy/` is adapted from **FreeProxy** by
xVanTuring, licensed under the **Apache License 2.0**. Modified files carry a
marker comment at the top.

* Attribution: [`THIRD_PARTY_NOTICES.md`](THIRD_PARTY_NOTICES.md)
* License text: [`licenses/FreeProxy-APACHE-2.0.txt`](licenses/FreeProxy-APACHE-2.0.txt)

Both are also shipped inside the APK under `assets/`. They are a license
condition rather than documentation, and must not be folded into this file or
removed.

The remaining DragonTCP glue, UI, Go transport, and server code is provided as
part of this project.
