# Android and Quest LAN multiplayer

## Implemented scope

Android and the flat Meta Quest 3 build enable Tiberian Dawn's retained local
network path. The engine creates the existing `UDPInterfaceClass`, opens a
nonblocking IPv4 UDP socket on port 1234, broadcasts classic lobby discovery,
and sends established match traffic directly to each peer.

This does not introduce Android-specific game rules or a new wire format. It
retains the same classic lobby, two-to-six-player limit, peer-to-peer lockstep,
packet sequencing, acknowledgement/retry behavior, scenario CRC, and recurring
simulation CRC used by Apple LAN multiplayer. Presentation, language, touch,
controller, and PAN settings do not enter the deterministic simulation state.

The Android transport additionally rejects zero-length or oversized datagrams
before copying them and caps incoming and outgoing transport queues at 512
packets. A single game-thread poll consumes at most 128 received datagrams so a
busy or hostile socket cannot monopolize a simulation tick. Network callbacks
do not run engine code; SDL's game thread polls and drains the socket through
the existing network service point.

The Android choice dialog uses the retained `MULTIPLAYER_NETWORK` completion
value for both the first **Local Network** item and the final confirmation of a
successfully connected private relay room. The native hand-off therefore checks
the relay-ready state: a plain local choice creates `UDPInterfaceClass`, while a
ready relay room creates the Android relay adapter. Without this distinction a
LAN choice incorrectly tried to open a relay socket before the classic lobby,
which returned the user to the menu instead of showing **Join Network Game**.

### Quest broadcast compatibility

The original UDP discovery code put the all-hosts address
`255.255.255.255` at the front of its destination list. Some Android/Horizon
network stacks reject that destination even when the WLAN itself is healthy.
The failed datagram then remained at the queue head, so the valid directed
WLAN broadcast was never attempted. This matches the symptom “the Mac hosts a
room, but Quest finds nothing” while ordinary local UDP still works.

The transport now enumerates every active IPv4 interface, uses its advertised
`ifa_broadaddr`, and derives a directed broadcast from the interface address
and netmask when the operating system omits `ifa_broadaddr`. The directed
addresses are tried first; `255.255.255.255` remains a de-duplicated
last-resort candidate. A non-retryable `sendto` error drops only that one
destination attempt instead of blocking all later destinations. This keeps the
legacy UDP port, packet bytes, product ID, and compatibility version unchanged.

When the Quest activity resumes, Java requests an address refresh. The actual
interface-list mutation runs on the existing game/network thread, avoiding a
concurrent change to the transport queues. The socket remains bound to UDP
port 1234, and no Android multicast permission or new manifest permission is
needed.

## Permission and network requirements

The manifest declares only:

```xml
<uses-permission android:name="android.permission.INTERNET" />
```

At the current `targetSdk 35`, this normal install-time permission covers raw
UDP sockets and does not create a runtime prompt. `ACCESS_NETWORK_STATE` is not
needed because the game does not query Android connectivity state.
`CHANGE_WIFI_MULTICAST_STATE` is not needed because discovery is IPv4
broadcast, not IP multicast.

Android 17 changes this contract for applications that target SDK 37 or newer:
direct UDP unicast/broadcast then requires the dangerous
`ACCESS_LOCAL_NETWORK` permission and a runtime grant. The current target must
not request that future permission early. Raising `targetSdk` to 37 is therefore
a release gate: add a localized rationale and denial/revocation flow before the
version change. See Android's current
[Local network permission guidance](https://developer.android.com/privacy-and-security/local-network-permission).

All players must be on a LAN that permits peer-to-peer UDP and broadcast on
port 1234. Guest Wi-Fi, access-point client isolation, enterprise Wi-Fi, VPNs,
and host firewalls commonly block discovery. Each player needs the same app
compatibility version, map, and legally imported game data.

## Player flow

1. Connect every device to the same non-isolated Wi-Fi/LAN.
2. Start the same application build and choose the same German or English game
   data as desired; UI language itself does not need to match.
3. Choose **Multiplayer / Mehrspieler**.
4. Choose **Local Network / Lokales Netzwerk**.
5. On one device choose the classic new-game action. On the other device wait
   for that game to appear, join it, choose side/color, and mark ready.
6. Start a short match and verify commands, chat, win/lose, surrender, and exit
   on both devices.

## Lifecycle contract

Android and Horizon OS may pause or suspend a flat app when its panel loses
focus or the user returns Home. The socket is polled only by the game thread;
there is no background callback entering simulation code. While paused, the
lockstep simulation cannot advance. A quick return can recover through the
original retries and reconnect dialog. A long suspension, process termination,
host departure, Wi-Fi change, or address change ends the first-release match.
There is no join-in-progress, state snapshot, or host migration.

## Build and static verification

From the Android repository:

```sh
cd android
JAVA_HOME='/Applications/Android Studio.app/Contents/jbr/Contents/Home' \
  ./gradlew -PandroidCmakeVersion=4.4.2 :app:assembleDebug

unzip -l app/build/outputs/apk/debug/app-debug.apk | \
  grep -E 'lib/(arm64-v8a|x86_64)/(libmain|libSDL2)\\.so'

apkanalyzer manifest permissions app/build/outputs/apk/debug/app-debug.apk
```

Acceptance requires `arm64-v8a` for Quest/physical Android, `x86_64` for the
AVD, and `android.permission.INTERNET` in the packaged manifest.

## Emulator and physical acceptance

One AVD can verify that the menu and lobby open, that the APK has the permission,
and that the UDP socket binds. It cannot prove discovery or deterministic play:
a complete test needs two independently addressed peers. Two standard AVDs may
also both sit behind emulator NAT and are therefore not a reliable LAN test.

The practical matrix when Quest 3 is the only physical Android device is:

- Quest 3 to macOS on the same Wi-Fi;
- Quest 3 to iPadOS on the same Wi-Fi;
- optionally Quest 3 to another sideloaded Android device when available.

For each pairing, test host in both directions, lobby discovery, ready/start,
ten minutes of commands and chat, deliberate panel focus loss, a short Home and
resume, Wi-Fi disable/enable, clean exit, and immediate re-host. Then run a
60-minute two-player soak and watch for a stuck lobby, desync/reconnect dialog,
unbounded memory growth, or inability to bind port 1234 after leaving a match.

Useful diagnostics are:

```sh
adb -s "$ANDROID_DEVICE" logcat -s TiberianDawn SDL
adb -s "$ANDROID_DEVICE" shell ip -4 addr
adb -s "$ANDROID_DEVICE" shell ip route
adb -s "$ANDROID_DEVICE" shell dumpsys package org.tiberiandawn.android
adb -s "$ANDROID_DEVICE" shell ss -lun
```

On a Quest 3, `ip -4 addr` should show a WLAN address and `/24` route matching
the Mac's Wi-Fi subnet. For example, the test Quest currently reports
`192.168.178.187/24` on `wlan0`, so its directed broadcast is
`192.168.178.255`. `ss` may not expose the application socket because Horizon
denies the netlink query; the engine log and a visible lobby are the primary
checks. On the Mac, this optional packet-level probe shows whether a query
leaves/arrives on the correct Wi-Fi interface (replace `en0` if necessary):

```sh
sudo tcpdump -ni en0 'udp port 1234'
```

Start the Mac host first, then open the Quest local-network lobby. The capture
should show Quest queries and the Mac's replies without requiring a protocol
change.

The deterministic host-side regression probe can be compiled without the
full game data or an Android SDK:

```sh
c++ -std=c++11 -Wall -Wextra -Werror -I. -Icommon \
  tests/udp_lan_protocol.cpp -o /tmp/td-udp-lan-protocol
/tmp/td-udp-lan-protocol
# udp_lan_protocol: PASS
```

It sends a retained global-channel header (magic `0x1234`, product
`0xaa01`, no-ACK query) through UDP and checks that the exact bytes arrive.
This confirms host socket/ABI handling and wire compatibility; only a real
Quest-to-Mac session confirms access-point broadcast policy and the full lobby.

## Internet relay boundary

Android now has an audited private-room adapter in
`platform/android/android_relay_transport.cpp` and
`AndroidRelayClient.java`. It uses the pinned OkHttp 4.12.0 WebSocket client
with the Android platform certificate/hostname verifier and accepts only the
fixed `wss://sportaktivfitness.de/tiberian-dawn-relay` endpoint. No custom
TrustManager, cleartext fallback, hand-written TLS, or alternate wire format
is used.

The adapter reuses `common/relay_protocol.*` unchanged. Binary frames are
bounded to 1048 bytes, receive queues to 512 frames, and a game-thread poll
drains at most 128 frames per tick. OkHttp callbacks only append validated-size
bytes to that queue; they never enter engine code. Create/join dialogs use the
same invitation and compatibility values as the Apple adapter and localize
user-facing errors in German and English. The native selector chooses between
the existing LAN UDP adapter and the relay before the classic lobby starts.

The production endpoint currently passes the real two-client WSS create/join
and binary-routing smoke test, and the local relay suite covers room capacity,
malformed control/error paths, compatibility rejection, one-attempt join
hardening, and room isolation. The remaining acceptance matrix is physical:
Android/Quest to macOS/iPad routing with imported data, production certificate
validation on the device, suspend/resume, disconnect, and two-player
lockstep/soak. The relay remains a beta until that hardware matrix passes.
