From 75bc72a88411ec0b734a74b6a11db46c4f1597a3 Mon Sep 17 00:00:00 2001 From: Siavash Sameni Date: Fri, 10 Apr 2026 15:20:12 +0400 Subject: [PATCH] docs: add BRANCH-android-rewrite.md and update ARCH/ADMIN/USER_GUIDE Documents the android-rewrite branch story end-to-end: - Why the Kotlin+JNI stack was abandoned (stack overflow, libcrypto TLS race, __init_tcb TCB leak, ring runtime reuse crash) - The Tauri 2.x Mobile pivot that reuses the desktop codebase verbatim - Android-specific pieces: wzp-native standalone cdylib loaded via libloading, android_audio.rs JVM routing, Oboe audio config quirks - Build pipeline via build-tauri-android.sh + wzp-android-builder image - Known quirks (API 34/36 coexistence, NDK path absolutes, etc.) Also appends shared-doc sections (identical on both branches): - ARCHITECTURE.md: "Audio Backend Architecture (Platform Matrix)" covering CPAL / VPIO / WASAPI / Oboe backends, selection matrix, the wzp-native cdylib rationale, and the vendored audiopus_sys fix. - ADMINISTRATION.md: "Build Pipelines" with Docker images (wzp-android-builder, wzp-windows-builder), per-pipeline usage (Android APK, Linux x86_64, Windows .exe), the Hetzner Cloud alternative, ntfy/rustypaste integration, and credential locations. - USER_GUIDE.md: "Direct 1:1 Calling (Desktop + Android)" covering history + recent contacts + deregister UI, and "Windows AEC Variants" explaining the AEC vs noAEC builds and driver caveats. Co-Authored-By: Claude Opus 4.6 (1M context) --- docs/ADMINISTRATION.md | 120 ++++++++++++++++++++++++++++ docs/ARCHITECTURE.md | 68 ++++++++++++++++ docs/BRANCH-android-rewrite.md | 139 +++++++++++++++++++++++++++++++++ docs/USER_GUIDE.md | 49 ++++++++++++ 4 files changed, 376 insertions(+) create mode 100644 docs/BRANCH-android-rewrite.md diff --git a/docs/ADMINISTRATION.md b/docs/ADMINISTRATION.md index 04dd1b6..043ebd6 100644 --- a/docs/ADMINISTRATION.md +++ b/docs/ADMINISTRATION.md @@ -625,3 +625,123 @@ curl -s http://relay-host:9090/metrics | grep wzp_relay_active_sessions # Check federation probe health curl -s http://relay-host:9090/metrics | grep wzp_probe_up ``` + +## Build Pipelines + +All production artifacts (Android APK, Linux x86_64 binaries, Windows `.exe`) are built on **SepehrHomeserverdk** using Docker, not on developer workstations. The pipelines are fire-and-forget: a local script invokes a `tmux` session on the remote, the build runs in a Docker container, and the artifact is uploaded to `paste.dk.manko.yoga` (rustypaste) with a notification sent to `ntfy.sh/wzp` on start and completion. + +### Docker images + +Two long-lived images live on the remote: + +| Image | Used by | Base | Key contents | +|---|---|---|---| +| `wzp-android-builder` | Android APK (Tauri mobile + legacy Kotlin), Linux x86_64 relay/CLI | Debian bookworm | Rust stable with Android targets, cargo-ndk, NDK 26.1, Android SDK (API 34 + 35 + 36), JDK 17, Gradle 8.5, Node.js 20, cmake, ninja, tauri-cli 2.x | +| `wzp-windows-builder` | Windows x86_64 `.exe` | Debian bookworm | Rust stable with `x86_64-pc-windows-msvc` target, cargo-xwin (with pre-warmed MSVC CRT + Windows SDK cache), Node.js 20, cmake, ninja, clang, lld, nasm | + +Both images are rebuilt rarely — once the base toolchain is stable, rebuilds are only needed to pick up new dependencies or security patches. + +**Rebuilding an image** (fire-and-forget, ~10 min on a warm base): + +```bash +# Windows +./scripts/build-windows-docker.sh --image-build + +# Android (upload and rebuild handled by the Android build script itself — see +# its --image-build flag or equivalent) +``` + +The `--image-build` flag uploads the local Dockerfile to the remote, kicks off `docker build` under `nohup`, and returns immediately. Monitor with: + +```bash +ssh SepehrHomeserverdk 'tail -f /tmp/wzp-windows-image-build.log' +``` + +### Pipeline: Android APK (Tauri Mobile) + +```bash +./scripts/build-tauri-android.sh # Full: pull + build + upload + notify +./scripts/build-tauri-android.sh --no-pull # Skip git fetch +./scripts/build-tauri-android.sh --clean # Force-clean Rust target +``` + +- **Branch**: `android-rewrite` +- **Image**: `wzp-android-builder` +- **Build command**: `cargo tauri android build --release` +- **Output**: `wzp-release.apk` → uploaded to rustypaste +- **Notifications**: start + completion to `ntfy.sh/wzp` +- **Remote artifact path**: `/mnt/storage/manBuilder/data/cache-android/target/…/release/app-release.apk` + +### Pipeline: Linux x86_64 (relay + CLI + bench + web) + +```bash +./scripts/build-linux-docker.sh # Fire-and-forget +./scripts/build-linux-docker.sh --no-pull # Skip git fetch +./scripts/build-linux-docker.sh --clean # Force-clean target +./scripts/build-linux-docker.sh --install # Wait for completion and download locally +``` + +- **Branch**: `feat/android-voip-client` (script default — override by editing the script or passing an env var) +- **Image**: `wzp-android-builder` (shared, not a separate Linux-only image) +- **Targets built**: `wzp-relay`, `wzp-client`, `wzp-client-audio` (with `--features audio`), `wzp-web`, `wzp-bench` +- **Output**: `wzp-linux-x86_64.tar.gz` with all five binaries → uploaded to rustypaste +- **Local landing dir** (with `--install`): `target/linux-x86_64/` + +### Pipeline: Windows x86_64 (`wzp-desktop.exe`) + +```bash +./scripts/build-windows-docker.sh # Full: pull + build + download locally +./scripts/build-windows-docker.sh --no-pull # Skip git fetch +./scripts/build-windows-docker.sh --rust # Force-clean target-windows cache +./scripts/build-windows-docker.sh --image-build # Rebuild the Docker image (fire-and-forget) +``` + +- **Branch**: `feat/desktop-audio-rewrite` +- **Image**: `wzp-windows-builder` +- **Build command**: `cargo xwin build --release --target x86_64-pc-windows-msvc --bin wzp-desktop` +- **Output**: `wzp-desktop.exe` (~16 MB) → downloaded to `target/windows-exe/wzp-desktop.exe`, also uploaded to rustypaste +- **Target cache volume**: `target-windows` (separate from the Android target cache to avoid triple cross-contamination) +- **Shared cache volumes**: `cargo-registry`, `cargo-git` (shared with Android — both pipelines pull the same crates) + +**A/B-preserving workflow** for testing audio backends: rename the prior `.exe` before re-running the build, so both coexist: + +```bash +# Preserve prior build as the noAEC baseline +mv target/windows-exe/wzp-desktop.exe target/windows-exe/wzp-desktop-noAEC.exe +./scripts/build-windows-docker.sh +ls -la target/windows-exe/ +# wzp-desktop-noAEC.exe (previous build) +# wzp-desktop.exe (new build) +``` + +### Alternative pipeline: Windows via Hetzner Cloud VPS + +For situations where Docker image rebuilds would be disruptive, or for one-shot debug builds on a clean machine: + +```bash +./scripts/build-windows-cloud.sh # Full: create VM → build → download → destroy +./scripts/build-windows-cloud.sh --prepare # Create VM + install deps, don't build +./scripts/build-windows-cloud.sh --build # Build on existing VM +./scripts/build-windows-cloud.sh --transfer # Download .exe from existing VM +./scripts/build-windows-cloud.sh --destroy # Delete the VM +WZP_KEEP_VM=1 ./scripts/build-windows-cloud.sh # Don't auto-destroy after successful build +``` + +- **Provider**: Hetzner Cloud +- **Default server type**: `cx33` (8 GB RAM, 8 vCPU — `cx23` with 4 GB OOMs on the tauri+rustls cross-compile) +- **Image**: `ubuntu-24.04` +- **SSH key**: must be named `wz` in Hetzner and loaded in the local ssh-agent +- **Reminder**: set `WZP_KEEP_VM=1` for multi-build sessions, then **remember to `--destroy` at end of day** so the VM isn't left running overnight. This is tracked in the auto-memory as `feedback_keep_windows_builder_vm.md`. + +### Notifications + +All pipelines post to `https://ntfy.sh/wzp`. Subscribe from your phone via the [ntfy.sh app](https://ntfy.sh/) to get push notifications on build start/success/failure. Messages include the short git hash and the rustypaste URL on success: + +``` +WZP Windows build OK [03a80a3] (16M) +https://paste.dk.manko.yoga//wzp-desktop.exe +``` + +### Rustypaste credentials + +Build pipelines read `rusty_address` and `rusty_auth_token` from the `.env` file at `/mnt/storage/manBuilder/.env` on SepehrHomeserverdk. Local scripts that upload directly (`build-windows-cloud.sh` when run in `--transfer` mode) read from `~/.wzp/rustypaste.env` with the same variable names. Both files must be kept in sync manually if rotated. diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 2024aa1..bff44ae 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -872,3 +872,71 @@ warzonePhone/ | wzp-relay | 40 + 4 integration | Room ACL, session mgmt, metrics, probes, mesh, trunking | | wzp-client | 30 + 2 integration | Encoder/decoder, quality adapter, silence, drift, sweep | | wzp-web | 2 | Metrics | + +## Audio Backend Architecture (Platform Matrix) + +WarzonePhone's audio I/O goes through one of four backends depending on the target platform and feature flags. All backends expose the same public API (`AudioCapture::start() → AudioCapture { ring(), stop() }`) via conditional re-exports in `crates/wzp-client/src/lib.rs`, so the `CallEngine` above the audio layer doesn't know or care which backend is running. + +``` + ┌─────────────────────────────────────────────┐ + │ CallEngine (platform-agnostic) │ + │ reads PCM from AudioCapture::ring() │ + │ writes PCM to AudioPlayback::ring() │ + └────────────────────┬────────────────────────┘ + │ + ┌─────────────────────┼─────────────────────┐ + │ │ │ + ▼ ▼ ▼ + ┌───────────────┐ ┌────────────────┐ ┌───────────────┐ + │ audio_io │ │ audio_vpio │ │ audio_wasapi │ + │ (CPAL) │ │ (Core Audio │ │ (Windows │ + │ │ │ VoiceProc IO) │ │ IAudioClient2│ + │ All platforms │ │ macOS only │ │ Windows │ + │ (baseline) │ │ feature=vpio │ │ feature= │ + │ │ │ │ │ windows-aec │ + └───────────────┘ └────────────────┘ └───────────────┘ + │ + ▼ on Android only + ┌───────────────┐ + │ wzp-native │ + │ (Oboe bridge │ + │ via dlopen) │ + │ │ + │ Android only │ + │ libloading │ + └───────────────┘ +``` + +### Backend selection matrix + +| Platform | Capture | Playback | OS AEC | Feature flags | +|---|---|---|---|---| +| macOS | VoiceProcessingIO (native Core Audio) | CPAL | **Yes** — Apple's hardware-accelerated AEC (same AEC as FaceTime, iMessage audio, Voice Memos) | `audio`, `vpio` | +| Windows (AEC build) | Direct WASAPI with `AudioCategory_Communications` | CPAL | **Yes** — Windows routes the capture stream through the driver's communications APO chain (AEC + NS + AGC), driver-dependent quality | `audio`, `windows-aec` | +| Windows (baseline) | CPAL (WASAPI shared mode) | CPAL | No | `audio` | +| Linux | CPAL (ALSA / PulseAudio) | CPAL | No | `audio` | +| Android (Tauri Mobile) | Oboe via `wzp-native` cdylib, `Usage::VoiceCommunication` + `MODE_IN_COMMUNICATION` | Same Oboe stream | Depends on device (some Android devices apply AEC to the voice-communication stream, most do not) | none (`wzp-client` compiled with `default-features = false`) | + +### Why `wzp-native` is a standalone cdylib + +On Android, the audio backend lives in a separate cdylib crate (`crates/wzp-native`) that `wzp-desktop`'s lib crate loads at runtime via `libloading`. It is **not** linked as a regular Rust dep. + +This is deliberate. rust-lang/rust#104707 documents that a crate with `crate-type = ["cdylib", "staticlib"]` leaks non-exported symbols from the staticlib into the cdylib. On Android, that caused Bionic's private `__init_tcb` / `pthread_create` symbols to be bound LOCALLY inside our `.so` instead of resolved dynamically against `libc.so` at `dlopen` time — which crashed the app at launch as soon as `tao` tried to `std::thread::spawn()` from the JNI `onCreate` callback. + +Keeping `wzp-native` in its own cdylib and loading it via `libloading` means: + +1. The app's own `.so` has `crate-type = ["cdylib", "rlib"]` only — no `staticlib`, no symbol leak. +2. `libwzp_native.so` is loaded via `System.loadLibrary` from the JVM side (or `dlopen` from Rust), which triggers the normal Bionic resolver and binds all private symbols against `libc.so` at load time. +3. The C/C++ Oboe bridge is fully isolated inside `libwzp_native.so`'s symbol space — no chance of its archives leaking into `wzp-desktop`'s `.so`. + +See `docs/BRANCH-android-rewrite.md` for the full incident postmortem and `docs/incident-tauri-android-init-tcb.md` for the debug log. + +### Vendored `audiopus_sys` for libopus / clang-cl cross-compile + +The workspace root carries a vendored copy of `audiopus_sys` at `vendor/audiopus_sys/` with a patched `opus/CMakeLists.txt`. This is needed because libopus 1.3.1 gates its per-file `-msse4.1` / `-mssse3` `COMPILE_FLAGS` behind `if(NOT MSVC)`, and under `clang-cl` (used by `cargo-xwin` for Windows cross-compiles) CMake sets `MSVC=1` unconditionally — so the SIMD source files compile without the required target feature and fail to link the intrinsic `always_inline` functions. + +The patch introduces an `MSVC_CL` variable that is true only for real `cl.exe` (distinguished via `CMAKE_C_COMPILER_ID STREQUAL "MSVC"`), and flips the eight `if(NOT MSVC)` SIMD guards to `if(NOT MSVC_CL)` so clang-cl gets the GCC-style per-file flags. Wired in via `[patch.crates-io] audiopus_sys = { path = "vendor/audiopus_sys" }` at the workspace root. + +This does not affect macOS or Linux builds — on those platforms `MSVC=0` everywhere so the patched logic behaves identically to upstream. + +Upstream tracking: xiph/opus#256, xiph/opus PR #257 (both stale). diff --git a/docs/BRANCH-android-rewrite.md b/docs/BRANCH-android-rewrite.md new file mode 100644 index 0000000..1ef7178 --- /dev/null +++ b/docs/BRANCH-android-rewrite.md @@ -0,0 +1,139 @@ +# Branch: `android-rewrite` + +Pivot away from the legacy Kotlin + JNI Android client to a pure-Rust **Tauri 2.x Mobile** app that shares the same frontend and backend code as the desktop client. + +## Why this branch exists + +The Kotlin + JNI stack was a crash factory. Every failure mode we hit was at the Kotlin ↔ Rust boundary, and each fix uncovered the next layer of the onion: + +| Symptom | Root cause | Fix | +|---|---|---| +| App crashed on launch before `onCreate` returned | `__init_tcb` / `pthread_create` bionic private symbols leaking out of `libwzp_android.so` because the Rust crate used `crate-type = ["cdylib", "staticlib"]`. rust-lang/rust#104707 documents that staticlib alongside cdylib leaks non-exported symbols from the staticlib into the cdylib, and Bionic's private internal pthread symbols got bound LOCALLY inside our `.so` instead of resolved against `libc.so` at `dlopen` time | Dropped `staticlib` from the crate-type list. `crate-type = ["cdylib", "rlib"]` only. | +| Stack overflow on `place_call` | `Dispatchers.IO` threads have a ~512 KB stack, too small for the Rust signal-connect path that does TLS handshake + quinn setup inside one closure | Launched JNI calls from a dedicated `java.lang.Thread` with an explicit 8 MB stack | +| `ring` / `libcrypto` TLS reuse crash on second call | tokio runtime got dropped between calls, but `ring` keeps a TLS-stored SSL context that is invalidated when the runtime thread is reused by a new runtime — `ring` sees stale context and segfaults | Single long-lived tokio runtime for the entire signal client lifetime; split `start()` into an inline `connect+register` path and a `run()` path on a separate thread to avoid the `thread::spawn` closure's stack overflow | +| Null dereference on register with fresh install | Identity seed file empty when it existed-but-was-blank, Rust side deref'd the zero-length slice | Generate seed if empty on register | + +Every fix kept the app limping along but the fundamental design problem remained: **state management was split across a Kotlin ViewModel and a Rust engine, with a hand-rolled JNI bridge in between that had to be perfect to not crash**. The working desktop Tauri client (with the same Rust backend) had none of these problems because it spoke to the Rust code via in-process `invoke()` from a WebView, not JNI. + +So: rewrite the Android app as a **Tauri 2.x Mobile app**, reusing the entire desktop codebase verbatim (`main.ts`, `style.css`, `index.html`, `main.rs`, `engine.rs` — everything). Tauri Mobile added Android support in v2, it's production-ready, and it eliminates the JNI boundary entirely. + +The incident postmortem lives at [`docs/incident-tauri-android-init-tcb.md`](incident-tauri-android-init-tcb.md). + +## Architecture + +``` +┌─────────────────────────────────────────────────┐ +│ Tauri 2.x Mobile │ +│ │ +│ Android WebView ────────── HTML/JS/CSS │ ← Shared with desktop +│ │ (main.ts) │ +│ │ │ +│ invoke() ─────────────── Rust Commands │ ← Shared with desktop +│ (main.rs) │ +│ │ │ +│ ┌───────────────┼────────────┐ │ +│ │ │ │ │ +│ SignalMgr CallEngine Identity │ ← Shared crates +│ (signal_hub) (wzp-client) (wzp-crypto)│ +│ │ │ │ +│ │ │ │ +│ ▼ ▼ │ +│ QUIC to relay Oboe audio (Android) │ +│ via wzp-native cdylib │ +└─────────────────────────────────────────────────┘ +``` + +**What is reused from desktop verbatim** (zero rewrite): + +- `desktop/src/main.ts` — entire frontend +- `desktop/src/style.css` — all styling +- `desktop/src/identicon.ts` — identicon rendering +- `desktop/index.html` — HTML structure +- `desktop/src-tauri/src/main.rs` — all Tauri commands (`connect`, `disconnect`, `register_signal`, `place_call`, …) +- `desktop/src-tauri/src/engine.rs` — `CallEngine` wrapper + +**What is Android-specific**: + +- `desktop/src-tauri/src/android_audio.rs` — JVM-side audio routing (`AudioManager.setSpeakerphoneOn` for earpiece/speaker toggle). Runs from Tauri's existing JNI context — no hand-rolled bridge, Tauri owns the JVM hookup. +- `desktop/src-tauri/src/wzp_native.rs` — runtime `dlopen` of `libwzp_native.so`, a standalone cdylib crate (`crates/wzp-native`) that owns all C++ (Oboe bridge). Kept in its own crate so its C/C++ static archives never get statically linked into `wzp-desktop`'s `.so`, which would re-trigger the `__init_tcb` / pthread leak. +- `crates/wzp-native/` — the standalone C++/Oboe bridge cdylib. Loaded via `libloading` at runtime from `wzp_native.rs`. Provides capture + playout streams using Oboe's `Usage::VoiceCommunication` + `MODE_IN_COMMUNICATION` combo. +- Android-specific target dependencies in `desktop/src-tauri/Cargo.toml` (`jni`, `ndk-context`, `libloading`) — no CPAL, no VPIO. + +## Key architectural decisions + +### 1. `wzp-native` as a standalone cdylib loaded via `libloading` + +The alternative — linking `wzp-native` as a regular Rust dep with C++ static archives — would cause the same `__init_tcb` crash that killed the Kotlin version. By making `wzp-native` its own cdylib and `dlopen`-ing it at runtime, Bionic's `libc.so` resolves every symbol at load time the way it's supposed to, and no private TCB symbols leak. + +### 2. `crate-type = ["cdylib", "rlib"]` only (no `staticlib`) + +Same reason. The `rlib` output is needed so the `wzp-desktop` binary target can link against the library; `cdylib` is needed for Android's `System.loadLibrary`; `staticlib` would reintroduce the symbol-leak bug. + +### 3. Oboe audio config + +`Usage::VoiceCommunication` + Java-side `MODE_IN_COMMUNICATION`. **Never** call `setAudioApi(AAudio)` explicitly — on some devices (Nothing Phone in particular) it causes Oboe to open the wrong stream type and audio goes silent. Let Oboe pick the audio API automatically. This is documented in the auto-memory `project_tauri_android_audio.md`. + +### 4. Speaker/earpiece toggle uses `tokio::task::spawn_blocking` + +Oboe's `stop()` + `start()` cycle is synchronous and can block for 50–200 ms. Calling it on the tokio executor stalls every other async task (including the QUIC datagram loop), dropping audio packets. Wrapping the toggle in `spawn_blocking` isolates it to a dedicated thread pool. Fixed in commit `76a4c53`. + +## Build pipeline + +Docker on SepehrHomeserverdk, same pattern as the Android legacy pipeline and the Windows pipeline: + +``` +./scripts/build-tauri-android.sh # Full: pull + build + ntfy + rustypaste +./scripts/build-tauri-android.sh --pull # Explicit git pull (default) +./scripts/build-tauri-android.sh --clean # Blow away the Rust target cache +``` + +**Image**: `wzp-android-builder` (shared with the legacy Kotlin pipeline). The Dockerfile was extended to install Node.js 20 LTS, Android API level 36, build-tools 35.0.0, tauri-cli 2.x, and all four Android Rust targets on top of the legacy NDK 26.1 + cargo-ndk + Gradle setup. Both pipelines coexist in the same image. + +**Output**: `wzp-release.apk` uploaded to rustypaste, URL delivered via `ntfy.sh/wzp`. + +## Known quirks (Tauri Mobile specific) + +1. **tauri-cli `android init` writes absolute paths** into `gradle.properties` for the NDK path. Those paths are local to wherever `android init` was run, so they break any cross-machine build unless overridden with `ANDROID_NDK_HOME` at build time. The build script exports `ANDROID_NDK_HOME` explicitly to work around this. + +2. **API 36 vs API 34 coexistence**: the legacy Kotlin pipeline targets API 34, Tauri Mobile 2.x wants compileSdk 36. The shared Docker image installs both SDK levels so neither pipeline needs to reinstall. + +3. **Identity seed lives in Android-specific app data dir**: `/data/data/com.wzp.phone/files/.wzp/identity` instead of `$HOME/.wzp/identity`. The shared `load_or_create_seed()` function in `desktop/src-tauri/src/lib.rs` uses Tauri's `app_data_dir()` which resolves correctly on both Android and desktop — no per-platform code needed. + +4. **Direct calls on macOS previously hit an identity mismatch bug** — the `CallEngine` was using `$HOME/.wzp/identity` directly while `register_signal` used Tauri's `app_data_dir()`. Fixed by routing both through `load_or_create_seed()` (commit `2fd9465`). This was important for cross-platform consistency. + +## Current state (snapshot) + +What works: + +- Tauri Mobile scaffold builds and runs on Android +- Signal hub connect + register works +- Room mode (SFU group calls) works with Oboe audio +- Direct 1:1 calls work with full parity to desktop +- Speaker/earpiece toggle works without stalling the audio pipeline +- Call history, recent contacts, deregister UI all present (inherited from desktop) + +What remains (task list refs in parens): + +- Background service for keeping signal alive when app is backgrounded (#19) +- Proper permission requests (microphone, notifications) on first launch (#19) +- Incoming call notification while backgrounded (#19) +- App icon + splash screen (#19) + +## Testing + +- **Build**: `./scripts/build-tauri-android.sh` — verify the APK lands on rustypaste and installs on device. +- **Smoke test**: Install → open app → Register → Place call → Receive call. No crashes, audio flows both ways. +- **Speaker toggle**: During a call, toggle speaker/earpiece several times in rapid succession. Audio should never stop, and the toggle should respond within ~200 ms. +- **Stress test**: Call for 10+ minutes continuous. No memory growth, no packet loss beyond what's attributable to the network. + +## Files of interest + +| Path | Purpose | +|---|---| +| `desktop/src-tauri/src/lib.rs` | Shared Tauri commands (desktop + Android) | +| `desktop/src-tauri/src/android_audio.rs` | JVM-side speaker/earpiece routing | +| `desktop/src-tauri/src/wzp_native.rs` | Runtime dlopen of libwzp_native.so | +| `crates/wzp-native/` | Standalone C++/Oboe cdylib, loaded at runtime | +| `scripts/build-tauri-android.sh` | Remote Docker build pipeline | +| `scripts/Dockerfile.android-builder` | Shared Android Docker image (legacy + Tauri) | +| `docs/incident-tauri-android-init-tcb.md` | Postmortem of the Kotlin+JNI crash cascade | diff --git a/docs/USER_GUIDE.md b/docs/USER_GUIDE.md index 3c2adfc..390652e 100644 --- a/docs/USER_GUIDE.md +++ b/docs/USER_GUIDE.md @@ -457,3 +457,52 @@ Auto mode uses three tiers (Good, Degraded, Catastrophic). It does not use the S When you select a specific profile (not Auto), adaptive switching is disabled. The encoder stays at the selected profile regardless of network conditions. This is useful when you know your network quality and want consistent encoding, or when you want to force a specific bitrate. Note: The decoder always accepts all codecs. A manual quality selection only affects what you send, not what you receive. + +## Direct 1:1 Calling (Desktop + Android) + +In addition to room-mode group calls, you can place direct calls to a specific peer by fingerprint. Direct calls bypass room state entirely — the relay is used purely as a signaling gateway and for media relay. There is no need for the callee to join a room beforehand; they just need to be registered with the same signal hub. + +### UI elements in the direct-call panel + +- **Place call field** — paste a fingerprint (the long hex string you see under your own identity) and click Call. The callee sees a ringing UI. +- **Recent contacts row** — a horizontal strip of chips showing your most recently called/receiving peers. Click a chip to re-dial. Aliases are shown if the peer has one, otherwise a short fingerprint prefix. +- **Call history list** — every direct call you've placed, received, or missed, with direction indicator (↗ Outgoing, ↙ Incoming, ✗ Missed), the peer's alias (if known) or fingerprint prefix, and a timestamp. Click an entry to re-dial. +- **Deregister button** — drops your signal-hub registration without quitting the app. Useful when switching identities (e.g. testing with two accounts on one machine) or when you want to explicitly appear offline to peers. +- **Clear history button** — wipes the call history store. Does not affect current calls. + +### Live updates + +The call history updates in real time across all views via Tauri events (`history-changed`). Placing, answering, or missing a call immediately refreshes the history list and the recent contacts row — no manual refresh needed. + +### Default room + +On first launch, the room name in the room-mode panel defaults to `general` (changed from the prior `android` default so the desktop and Android clients don't silently talk past each other). You can still change it to any room name, and the last-used room is remembered across launches. + +### Random alias + +New installations derive a human-friendly alias from your identity seed — something like `silent-forest-41` or `bold-river-07`. It's deterministic, so reinstalling without changing your seed gives you the same alias. The alias is shown alongside your fingerprint in the header and is what peers see in their call history when they receive your call. + +You can override the alias in Settings → Identity if you want a specific name. + +## Windows AEC Variants + +The Windows desktop build ships in two variants for echo cancellation, depending on which backend you want to exercise. Both are `wzp-desktop.exe` binaries — only the internal audio backend differs. + +| Build | File | Capture backend | AEC | When to use | +|---|---|---|---|---| +| **noAEC baseline** | `wzp-desktop-noAEC.exe` | CPAL (WASAPI shared mode) | None | Headphone-only use, or for A/B comparison against the AEC build | +| **Communications AEC** | `wzp-desktop.exe` | Direct WASAPI with `AudioCategory_Communications` | **Yes** — Windows routes the capture stream through the driver's communications APO chain (AEC + noise suppression + automatic gain control) | Any speaker-mode call, laptop built-in speakers, anywhere echo is audible | + +**Quality caveat**: the communications AEC operates at the OS level and its algorithm depends on the audio driver's installed APO chain. On modern consumer laptops with Intel Smart Sound, Dolby, recent Realtek, or Windows 11 Voice Clarity, the quality is excellent (effectively matching what Teams/Zoom deliver). On generic class-compliant USB microphones or older drivers, the communications APO may not be present at all — in that case the build behaves identically to the noAEC baseline. + +If you hear echo on the AEC build, try these in order before escalating: + +1. **Check which capture device is selected as "Default Device - Communications"** in Windows Sound Settings → Recording tab. Right-click any device to set it. The AEC build opens the device marked as `eCommunications`, not `eConsole`, so changing the default-communications device changes what we capture from. +2. **Verify the driver exposes a communications APO**. Sound Settings → Recording → your mic → Properties → Advanced → look for an "Enhancements" or "Signal Enhancements" tab. If it's absent, the driver has no APOs and the AEC build effectively has no AEC. +3. **Try the classic Voice Capture DSP build** when it ships (tracked as task #26). That uses Microsoft's bundled software AEC (`CLSID_CWMAudioAEC`) which works on every Windows machine regardless of driver. + +### Installing the Windows builds + +1. Windows 10: install the [WebView2 Runtime Evergreen Bootstrapper](https://developer.microsoft.com/en-us/microsoft-edge/webview2/) first. Windows 11 has it pre-installed. +2. Copy `wzp-desktop.exe` (or `wzp-desktop-noAEC.exe`) to any directory and double-click. No installer needed. +3. First launch creates the config + identity store at `%APPDATA%\com.wzp.phone\`.