28 Commits

Author SHA1 Message Date
Siavash Sameni
264ef9c4d4 feat: relay ping with RTT, server TOFU, lock icons (Phase 2 backport)
Some checks failed
Mirror to GitHub / mirror (push) Failing after 40s
Build Release Binaries / build-amd64 (push) Failing after 3m48s
Rust JNI:
- nativePingRelay: QUIC connect with 3s timeout, returns RTT + server
  certificate fingerprint as JSON. Static method, no engine needed.

Kotlin:
- WzpEngine.pingRelay() static wrapper
- SettingsRepository: TOFU fingerprint persistence (tofu_{address} keys)
- CallViewModel: pingAllServers() coroutine, lockStatus() helper,
  PingResult/LockStatus data types
- InCallScreen: server chips show lock icon + RTT color (green/yellow),
  "Ping All" button

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-06 22:43:53 +04:00
Siavash Sameni
a9adb5cfd7 feat: identicons, tap-to-copy fingerprint, recent rooms (Phase 1 backport)
Some checks failed
Mirror to GitHub / mirror (push) Failing after 36s
Build Release Binaries / build-amd64 (push) Failing after 3m47s
Backport from desktop client to Android:

Identicons:
- New Identicon.kt composable: deterministic 5x5 symmetric Canvas pattern
  from fingerprint hash (same algorithm as desktop identicon.ts)
- Participant list shows identicon + name + tappable fingerprint
- Settings page shows identicon next to fingerprint

CopyableFingerprint:
- Tap any fingerprint text to copy to clipboard with Toast feedback
- Used in participant list and settings page

Recent rooms:
- SettingsRepository: persists last 5 (relay, room) pairs
- CallViewModel: saves on startCall, exposes as StateFlow
- InCallScreen: clickable chips that fill room + select matching server

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-06 22:37:46 +04:00
Siavash Sameni
a39b074d6e fix: DirectByteBuffer as class field — survives ART JIT OSR
Some checks failed
Mirror to GitHub / mirror (push) Failing after 36s
Build Release Binaries / build-amd64 (push) Failing after 3m58s
Previous attempt allocated DirectByteBuffer as local variables inside
runCapture/runPlayout. ART's JIT On-Stack Replacement nulled them
when recompiling the hot loop mid-execution.

Fix: allocate as class fields on AudioPipeline (captureDirectBuf,
playoutDirectBuf). Object fields live on the heap, immune to OSR
stack frame replacement.

Eliminates JNI array copies (GetShortArrayRegion/SetShortArrayRegion)
from the audio hot path, preventing ART GC SIGBUS crashes on
Android 16 with concurrent mark-compact GC.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-06 22:22:54 +04:00
Siavash Sameni
9cab6e2347 ci: skip build on CI-only file changes
Add paths-ignore for .gitea/** so build.yml doesn't waste runner time
when only workflow files are modified.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-06 22:13:29 +04:00
Siavash Sameni
5e93cb74f2 fix: filter tracing to INFO for wzp crates, WARN for jni crate
Some checks failed
Mirror to GitHub / mirror (push) Failing after 38s
Build Release Binaries / build-amd64 (push) Failing after 4m7s
The jni crate emits VERBOSE logs for every JNI method lookup (~10 lines
per call, 100+ calls/sec on audio threads). This floods logcat, consumes
CPU, and triggers system kills. Filter to only show INFO+ for our crates
and WARN+ for everything else.

Also fix build script: clean full Rust target to ensure libc++_shared.so
is always copied by cargo-ndk.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-06 21:37:29 +04:00
Siavash Sameni
b56b4a759c revert: use ShortArray audio path (DirectByteBuffer causes null ptr crash)
Some checks failed
Mirror to GitHub / mirror (push) Failing after 35s
Build Release Binaries / build-amd64 (push) Failing after 3m58s
DirectByteBuffer.clear() crashes with null pointer in ART's JIT OSR
compiled code on Android 16. Revert AudioPipeline to use the original
ShortArray writeAudio/readAudio path.

The DirectByteBuffer JNI functions remain in WzpEngine.kt and
jni_bridge.rs for future use once the OSR issue is resolved.

The original SIGBUS from ART GC is rare (~1 crash per 8 min call)
and doesn't warrant the DirectByteBuffer approach until we can
allocate the buffer as a class field outside the hot loop.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-06 21:17:15 +04:00
Siavash Sameni
6f99841cc7 fix: cloud build script — filter by server name, rsync upload, cx33
Some checks failed
Mirror to GitHub / mirror (push) Failing after 36s
Build Release Binaries / build-amd64 (push) Failing after 3m57s
- Filter hcloud by SERVER_NAME to avoid touching other servers
- Use rsync instead of tar (handles submodules, no macOS xattr spam)
- Default server type cx33
- Release APK failure is non-fatal (debug APK still produced)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-06 20:00:10 +04:00
Siavash Sameni
3b0811ce2e ci: add GitHub mirror workflow
Automatically pushes branches and tags to github.com:manawenuz/wzp.git
on every push to Forgejo. Uses GH_SSH_KEY secret for authentication.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-06 19:49:59 +04:00
Siavash Sameni
9eed94850d fix: DirectByteBuffer audio path — eliminate JNI array copies
Some checks failed
Build Release Binaries / build-amd64 (push) Failing after 3m43s
Adds nativeWriteAudioDirect / nativeReadAudioDirect JNI functions
that accept a DirectByteBuffer instead of ShortArray. The buffer's
native memory is accessed directly by Rust via pointer — no
GetShortArrayRegion / SetShortArrayRegion, no GC-managed array
copies on the audio hot path.

This fixes SIGBUS crashes on Android 16 where ART's concurrent
mark-compact GC crashes when flipping thread roots during JNI
array operations on MAX_PRIORITY audio threads.

Old ShortArray methods kept for backward compatibility.
AudioPipeline switched to use Direct variants.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-06 19:29:08 +04:00
Siavash Sameni
5e9718aeb2 docs: incident report — SIGBUS in ART GC during audio JNI calls
Some checks failed
Build Release Binaries / build-amd64 (push) Failing after 3m37s
Android 16's concurrent mark-compact GC crashes when flipping
thread roots on our MAX_PRIORITY audio threads during JNI calls
(AudioRecord.read / AudioTrack.write). Not our code — all crash
frames are in libart.so.

Proposed fixes:
- Short term: DirectByteBuffer to reduce JNI transitions
- Long term: Oboe native audio from Rust (no JNI, no GC)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-06 19:21:32 +04:00
Siavash Sameni
3093933602 fix: build script works on Ubuntu 24.04 (cmake 3.28) too
Some checks failed
Build Release Binaries / build-amd64 (push) Failing after 3m48s
cmake 3.28 works when ANDROID_NDK is set (not just ANDROID_NDK_HOME).
Relaxed version check from <=3.26 to <=3.30.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-06 19:00:06 +04:00
Siavash Sameni
4c6c909732 feat: comprehensive Android build script for Debian 12
Some checks failed
Build Release Binaries / build-amd64 (push) Failing after 3m56s
Documents WHY each version is pinned:
- cmake 3.25: 3.27+ rewrote Android-Determine.cmake with bugs
- NDK 26.1: NDK 27 scudo crashes on MTE devices (Nothing A059)
- JDK 17: Gradle 8.5 + AGP 8.2.0 official support
- ANDROID_NDK: cmake checks this, not ANDROID_NDK_HOME

Idempotent, works from clone or existing tree.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-06 18:37:12 +04:00
Siavash Sameni
33fab9a049 fix: vec allocation for AudioRing, catch_unwind on tracing init, profiling
Some checks failed
Build Release Binaries / build-amd64 (push) Failing after 3m49s
- AudioRing: use vec![].into_boxed_slice() instead of Box::new([]) to
  avoid 32KB stack allocation that crashes scudo on Android
- JNI bridge: wrap tracing_subscriber init in catch_unwind to survive
  sharded_slab allocation failures on some devices
- Engine: per-step encode profiling (avg_agc_us, avg_opus_us, avg_fec_us,
  avg_send_us) logged every 5 seconds in send stats

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-06 15:41:46 +04:00
Siavash Sameni
31d2306915 feat: per-step encode profiling in send task stats
Some checks failed
Build Release Binaries / build-amd64 (push) Failing after 3m48s
Adds average microsecond timings for each encode step:
- avg_agc_us: AGC processing
- avg_opus_us: Opus encoding
- avg_fec_us: FEC encode + repair generation
- avg_send_us: QUIC send_media
- avg_total_us: sum of above

Logged every 5 seconds in send stats. Resets each interval.
Use to identify which step is bottlenecking the encode loop
on devices where fps drops below 50.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-06 14:18:33 +04:00
Siavash Sameni
4af7c5f94c fix: AudioRing cursor desync + capture thread use-after-free
Some checks failed
Build Release Binaries / build-amd64 (push) Failing after 3m56s
AudioRing (reader-detects-lap architecture):
- Writer NEVER touches read_pos — fixes SPSC invariant violation
- Reader self-corrects when lapped (snaps read_pos forward)
- Power-of-2 capacity (16384 = 341ms) with bitmask indexing
- Added overflow_count and underrun_count diagnostics
- Wired ring health into engine stats and periodic logging

Capture thread use-after-free (drain latch):
- Added CountDownLatch(2) to AudioPipeline
- Audio threads count down after exiting their loops
- teardown() awaits latch (200ms timeout) before destroy()
- Guarantees no in-flight JNI calls when native handle is freed
- stopAudio() no longer nulls pipeline (teardown handles it)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-06 13:28:34 +04:00
Claude
6597b5bd86 docs: incident report + fix spec for capture thread use-after-free crash
Some checks failed
Build Release Binaries / build-amd64 (push) Failing after 3s
SIGSEGV on hangup: capture thread calls writeAudio() via JNI after
teardown() has freed the native engine handle. TOCTOU race between
the nativeHandle==0L check and destroy() on the ViewModel thread.

Fix: CountDownLatch(2) — audio threads count down after exiting loops,
teardown() awaits before destroy(). 2 Kotlin files, no Rust changes.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-06 09:21:35 +00:00
Claude
ae9d8526dd docs: implementation spec for AudioRing SPSC desync fix
Some checks failed
Build Release Binaries / build-amd64 (push) Failing after 3m51s
Complete spec for fixing the playout ring buffer cursor race that
causes 12-16s bidirectional silence mid-call. Includes exact code,
memory ordering rationale, unit tests, and verification steps.

Any agent can implement from this document alone.

See also: debug/INCIDENT-2026-04-06-playout-ring-desync.md

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-06 09:16:47 +00:00
Claude
4d54b6f9e4 docs: incident reports for send-task crash and playout ring desync
Some checks failed
Build Release Binaries / build-amd64 (push) Has been cancelled
Two root-caused bugs documented with full evidence:

1. Send task fatal exit on QUIC congestion (FIXED in 2092245)
   - send_media() Err(Blocked) caused break → killed entire call
   - Now drops packet and continues

2. Playout ring buffer cursor desync (ROOT-CAUSED, fix pending)
   - AudioRing::write() mutates read_pos from producer thread on overflow
   - Violates SPSC contract → reader/writer fight over read_pos
   - Causes 12-16s bidirectional silence ~25-30s into call
   - Both clients affected simultaneously

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-06 08:52:14 +00:00
Claude
2b3bdae440 fix: enable Rust tracing → Android logcat via tracing-android
Rust tracing subscriber was never initialized — all info!/warn!/error!
calls in the engine went to /dev/null. This meant our send/recv health
logging was invisible and we couldn't confirm the congestion fix was
active.

Now initializes tracing-android layer on first nativeInit(), routing
all Rust logs to logcat under tag "wzp_android". Also expanded logcat
filter in DebugReporter to capture engine-level log lines.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-06 08:03:28 +00:00
Claude
20922455bd fix: send task crash on QUIC congestion + AEC toggle + debug reporter
Root cause: send_media() returns Err(Blocked) when QUIC congestion
window is full. The send task treated ANY send error as fatal (break),
killing the entire call. Now send errors drop the packet and continue.

Also hardened recv task to survive transient errors and added health
logging (recv gap tracking, periodic stats) to both send and recv.

Relay: added comprehensive debug logging — recv gaps, lock contention,
forward latency, send errors — all per-participant with 5s stats.

Other changes:
- AEC toggle in Settings (persisted, applied on next call)
- Debug report: records call audio (WAV), RMS histogram (CSV), logcat,
  stats. Emailed as zip via Android share intent after call ends.
- Replaced LinearProgressIndicator with Box (compose version compat)
- FileProvider for sharing debug zip attachments

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-06 07:38:56 +00:00
Claude
e6564bab57 fix: mic mute crackling + add AEC/NoiseSuppressor + dedup room participants
Mic mute: the send loop now zeros the capture buffer when muted instead
of relying on write_audio() to skip writes. Previously stale ring data
and AGC amplification of near-silence caused crackling artifacts.

AEC: attach Android's hardware AcousticEchoCanceler to the AudioRecord
session. Also attach NoiseSuppressor when available. Both are released
on capture stop.

Room UI: deduplicate participants by fingerprint so ghost entries from
stale relay state don't show duplicate names.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-06 06:06:35 +00:00
Claude
aebf9156c0 fix: dedup participants in UI, wait for QUIC close ack before exiting
UI: deduplicate room participants by fingerprint so ghost entries from
stale relay state don't show duplicates.

Engine: after select! ends, call close_now() + connection.closed() with
500ms timeout to wait for the relay to acknowledge the CONNECTION_CLOSE.
Previously the close frame was queued but the runtime died before quinn
could retransmit if the first packet was lost.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-06 05:40:06 +00:00
Claude
9bbaec6b35 fix: use shutdown_timeout so QUIC CONNECTION_CLOSE actually gets sent
shutdown_background() killed the tokio runtime before quinn could send the
CONNECTION_CLOSE frame on the wire, so the relay never knew the client left.
Now use shutdown_timeout(500ms) to give quinn time to flush the close frame,
matching the desktop client pattern (which uses 2s timeout).

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-06 05:20:20 +00:00
Claude
dc66b60d18 fix: null alias display — Android JSONObject.optString returns literal "null"
o.optString("alias", null) returns the string "null" when the JSON value
is JSON null. Use o.isNull() check first. Also handle empty fingerprint
edge case with "unknown" fallback.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-06 05:04:47 +00:00
Claude
a9c4260b4e fix: close QUIC connection on hangup so relay removes participant immediately
stop_call() now calls close_now() on the stored transport handle before
killing the tokio runtime. This sends a QUIC CONNECTION_CLOSE frame so
the relay's recv loop breaks immediately, triggering leave() + RoomUpdate
broadcast. Previously the runtime was killed first, so transport.close()
never ran and the relay kept stale participants until idle timeout.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-06 04:58:24 +00:00
Claude
7eb136fcb3 fix: settings save button (back=discard), fix missing alias in featherchat tests
- Settings now uses draft state — changes only persist on explicit Save
- Back button discards unsaved changes
- Added applyServers() for batch server updates
- Added missing alias field to CallOffer in featherchat tests

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-06 04:30:23 +00:00
Claude
550a124972 fix: add missing alias arg to perform_handshake call in wzp-web
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-06 04:15:24 +00:00
Claude
0835c36d0f feat: settings page with persistence, client alias in handshake, fix null fingerprints
Some checks failed
Build Release Binaries / build-amd64 (push) Failing after 3m34s
- Add SettingsScreen with identity (alias, key backup/restore), audio defaults,
  server management, network prefs, and default room
- SettingsRepository persists all settings via SharedPreferences
- Auto-generate random display names on first launch (e.g. "Swift Wolf")
- Thread alias through CallOffer → relay handshake → RoomUpdate broadcast
- Derive caller fingerprint from identity key in relay handshake (fixes null
  fingerprints when --auth-url is not set)
- Persist identity seed for stable fingerprints across reconnects
- Add alias field to SignalMessage::CallOffer (serde default for backward compat)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-06 03:56:33 +00:00
37 changed files with 4165 additions and 146 deletions

View File

@@ -7,6 +7,8 @@ on:
- 'feat/*' - 'feat/*'
tags: tags:
- 'v*' - 'v*'
paths-ignore:
- '.gitea/**'
workflow_dispatch: workflow_dispatch:
env: env:

View File

@@ -0,0 +1,43 @@
name: Mirror to GitHub
on:
push:
branches:
- main
- 'feat/*'
- 'feature/*'
tags:
- '*'
jobs:
mirror:
runs-on: ubuntu-latest
container:
image: catthehacker/ubuntu:act-latest
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Push to GitHub
env:
GH_SSH_KEY: ${{ secrets.GH_SSH_KEY }}
run: |
mkdir -p ~/.ssh
echo "${GH_SSH_KEY}" > ~/.ssh/id_ed25519
chmod 600 ~/.ssh/id_ed25519
ssh-keyscan github.com >> ~/.ssh/known_hosts 2>/dev/null
git remote add github git@github.com:manawenuz/wzp.git
# Push the current branch
BRANCH="${GITHUB_REF#refs/heads/}"
TAG="${GITHUB_REF#refs/tags/}"
if [ "${GITHUB_REF}" != "${GITHUB_REF#refs/tags/}" ]; then
echo "Pushing tag: ${TAG}"
git push github "refs/tags/${TAG}" --force
else
echo "Pushing branch: ${BRANCH}"
git push github "HEAD:refs/heads/${BRANCH}" --force
fi

18
Cargo.lock generated
View File

@@ -43,6 +43,12 @@ dependencies = [
"pkg-config", "pkg-config",
] ]
[[package]]
name = "android_log-sys"
version = "0.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "85965b6739a430150bdd138e2374a98af0c3ee0d030b3bb7fc3bddff58d0102e"
[[package]] [[package]]
name = "android_system_properties" name = "android_system_properties"
version = "0.1.5" version = "0.1.5"
@@ -3180,6 +3186,17 @@ dependencies = [
"tracing-core", "tracing-core",
] ]
[[package]]
name = "tracing-android"
version = "0.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "12612be8f868a09c0ceae7113ff26afe79d81a24473a393cb9120ece162e86c0"
dependencies = [
"android_log-sys",
"tracing",
"tracing-subscriber",
]
[[package]] [[package]]
name = "tracing-attributes" name = "tracing-attributes"
version = "0.1.31" version = "0.1.31"
@@ -4018,6 +4035,7 @@ dependencies = [
"thiserror 2.0.18", "thiserror 2.0.18",
"tokio", "tokio",
"tracing", "tracing",
"tracing-android",
"tracing-subscriber", "tracing-subscriber",
"wzp-codec", "wzp-codec",
"wzp-crypto", "wzp-crypto",

View File

@@ -29,5 +29,15 @@
android:name="com.wzp.service.CallService" android:name="com.wzp.service.CallService"
android:foregroundServiceType="microphone" android:foregroundServiceType="microphone"
android:exported="false" /> android:exported="false" />
<provider
android:name="androidx.core.content.FileProvider"
android:authorities="${applicationId}.fileprovider"
android:exported="false"
android:grantUriPermissions="true">
<meta-data
android:name="android.support.FILE_PROVIDER_PATHS"
android:resource="@xml/file_paths" />
</provider>
</application> </application>
</manifest> </manifest>

View File

@@ -8,10 +8,21 @@ import android.media.AudioFormat
import android.media.AudioRecord import android.media.AudioRecord
import android.media.AudioTrack import android.media.AudioTrack
import android.media.MediaRecorder import android.media.MediaRecorder
import android.media.audiofx.AcousticEchoCanceler
import android.media.audiofx.NoiseSuppressor
import android.util.Log import android.util.Log
import androidx.core.content.ContextCompat import androidx.core.content.ContextCompat
import com.wzp.engine.WzpEngine import com.wzp.engine.WzpEngine
import java.io.BufferedOutputStream
import java.io.File
import java.io.FileOutputStream
import java.io.OutputStreamWriter
import java.nio.ByteBuffer
import java.nio.ByteOrder
import java.util.concurrent.CountDownLatch
import java.util.concurrent.TimeUnit
import kotlin.math.pow import kotlin.math.pow
import kotlin.math.sqrt
/** /**
* Audio pipeline that captures mic audio and plays received audio using * Audio pipeline that captures mic audio and plays received audio using
@@ -43,15 +54,38 @@ class AudioPipeline(private val context: Context) {
/** Capture (mic) gain in dB. 0 = unity. */ /** Capture (mic) gain in dB. 0 = unity. */
@Volatile @Volatile
var captureGainDb: Float = 0f var captureGainDb: Float = 0f
/** Whether to attach hardware AEC. Must be set before start(). */
var aecEnabled: Boolean = true
/** Enable debug recording of PCM + RMS histogram to cache dir. */
var debugRecording: Boolean = true
private var captureThread: Thread? = null private var captureThread: Thread? = null
private var playoutThread: Thread? = null private var playoutThread: Thread? = null
// DirectByteBuffers for zero-copy JNI audio transfer.
// Allocated as class fields (NOT locals) because ART's JIT OSR
// can null local variables when it replaces the stack frame mid-loop.
// These survive OSR because they're on the heap.
private val captureDirectBuf: ByteBuffer =
ByteBuffer.allocateDirect(FRAME_SAMPLES * 2).order(ByteOrder.LITTLE_ENDIAN)
private val playoutDirectBuf: ByteBuffer =
ByteBuffer.allocateDirect(FRAME_SAMPLES * 2).order(ByteOrder.LITTLE_ENDIAN)
/** Latch counted down by each audio thread after exiting its loop.
* stop() does NOT wait on this — teardown waits via awaitDrain(). */
private var drainLatch: CountDownLatch? = null
private val debugDir: File by lazy {
File(context.cacheDir, "wzp_debug").also { it.mkdirs() }
}
fun start(engine: WzpEngine) { fun start(engine: WzpEngine) {
if (running) return if (running) return
running = true running = true
drainLatch = CountDownLatch(2) // one for capture, one for playout
captureThread = Thread({ captureThread = Thread({
runCapture(engine) runCapture(engine)
drainLatch?.countDown() // signal: capture loop exited, no more JNI calls
// Park thread forever — exiting triggers a libcrypto TLS destructor // Park thread forever — exiting triggers a libcrypto TLS destructor
// crash (SIGSEGV in OPENSSL_free) on Android when a JNI-calling thread exits. // crash (SIGSEGV in OPENSSL_free) on Android when a JNI-calling thread exits.
parkThread() parkThread()
@@ -63,6 +97,7 @@ class AudioPipeline(private val context: Context) {
playoutThread = Thread({ playoutThread = Thread({
runPlayout(engine) runPlayout(engine)
drainLatch?.countDown() // signal: playout loop exited
parkThread() parkThread()
}, "wzp-playout").apply { }, "wzp-playout").apply {
isDaemon = true isDaemon = true
@@ -75,10 +110,20 @@ class AudioPipeline(private val context: Context) {
fun stop() { fun stop() {
running = false running = false
// Don't join threads are parked as daemons to avoid native TLS crash // Don't join threads — they are parked as daemons to avoid native TLS crash.
// Don't null thread refs or drainLatch — teardown() needs awaitDrain().
Log.i(TAG, "audio pipeline stopped (running=false)")
}
/** Block until both audio threads have exited their loops (max 200ms).
* After this returns, no more JNI calls to the engine will be made. */
fun awaitDrain(): Boolean {
val ok = drainLatch?.await(200, TimeUnit.MILLISECONDS) ?: true
if (!ok) Log.w(TAG, "awaitDrain: audio threads did not drain in 200ms")
captureThread = null captureThread = null
playoutThread = null playoutThread = null
Log.i(TAG, "audio pipeline stopped") drainLatch = null
return ok
} }
private fun applyGain(pcm: ShortArray, count: Int, db: Float) { private fun applyGain(pcm: ShortArray, count: Int, db: Float) {
@@ -89,6 +134,15 @@ class AudioPipeline(private val context: Context) {
} }
} }
private fun computeRms(pcm: ShortArray, count: Int): Int {
var sumSq = 0.0
for (i in 0 until count) {
val s = pcm[i].toDouble()
sumSq += s * s
}
return sqrt(sumSq / count).toInt()
}
private fun parkThread() { private fun parkThread() {
try { try {
Thread.sleep(Long.MAX_VALUE) Thread.sleep(Long.MAX_VALUE)
@@ -127,25 +181,89 @@ class AudioPipeline(private val context: Context) {
return return
} }
// Attach hardware AEC if available and enabled in settings
var aec: AcousticEchoCanceler? = null
var ns: NoiseSuppressor? = null
if (aecEnabled) {
if (AcousticEchoCanceler.isAvailable()) {
try {
aec = AcousticEchoCanceler.create(recorder.audioSessionId)
aec?.enabled = true
Log.i(TAG, "AEC enabled (session=${recorder.audioSessionId})")
} catch (e: Exception) {
Log.w(TAG, "AEC init failed: ${e.message}")
}
} else {
Log.w(TAG, "AEC not available on this device")
}
// Attach hardware noise suppressor if available
if (NoiseSuppressor.isAvailable()) {
try {
ns = NoiseSuppressor.create(recorder.audioSessionId)
ns?.enabled = true
Log.i(TAG, "NoiseSuppressor enabled")
} catch (e: Exception) {
Log.w(TAG, "NoiseSuppressor init failed: ${e.message}")
}
}
} else {
Log.i(TAG, "AEC disabled by user setting")
}
recorder.startRecording() recorder.startRecording()
Log.i(TAG, "capture started: ${SAMPLE_RATE}Hz mono, buf=$bufSize") Log.i(TAG, "capture started: ${SAMPLE_RATE}Hz mono, buf=$bufSize, aec=${aec?.enabled}, ns=${ns?.enabled}")
val pcm = ShortArray(FRAME_SAMPLES) val pcm = ShortArray(FRAME_SAMPLES)
// Debug: PCM file + RMS CSV
var pcmOut: BufferedOutputStream? = null
var rmsCsv: OutputStreamWriter? = null
val byteConv = ByteBuffer.allocate(FRAME_SAMPLES * 2).order(ByteOrder.LITTLE_ENDIAN)
var frameIdx = 0L
if (debugRecording) {
try {
pcmOut = BufferedOutputStream(FileOutputStream(File(debugDir, "capture.pcm")), 65536)
rmsCsv = OutputStreamWriter(FileOutputStream(File(debugDir, "capture_rms.csv")))
rmsCsv.write("frame,time_ms,rms\n")
} catch (e: Exception) {
Log.w(TAG, "debug recording init failed: ${e.message}")
}
}
try { try {
while (running) { while (running) {
val read = recorder.read(pcm, 0, FRAME_SAMPLES) val read = recorder.read(pcm, 0, FRAME_SAMPLES)
if (read > 0) { if (read > 0) {
applyGain(pcm, read, captureGainDb) applyGain(pcm, read, captureGainDb)
engine.writeAudio(pcm) // Zero-copy write via DirectByteBuffer (class field, survives JIT OSR)
captureDirectBuf.clear()
captureDirectBuf.asShortBuffer().put(pcm, 0, read)
engine.writeAudioDirect(captureDirectBuf, read)
// Debug: write raw PCM + RMS
if (pcmOut != null) {
byteConv.clear()
for (i in 0 until read) byteConv.putShort(pcm[i])
pcmOut.write(byteConv.array(), 0, read * 2)
}
if (rmsCsv != null) {
val rms = computeRms(pcm, read)
val timeMs = frameIdx * FRAME_SAMPLES * 1000L / SAMPLE_RATE
rmsCsv.write("$frameIdx,$timeMs,$rms\n")
}
frameIdx++
} else if (read < 0) { } else if (read < 0) {
Log.e(TAG, "AudioRecord.read error: $read") Log.e(TAG, "AudioRecord.read error: $read")
break break
} }
} }
} finally { } finally {
pcmOut?.close()
rmsCsv?.close()
recorder.stop() recorder.stop()
aec?.release()
ns?.release()
recorder.release() recorder.release()
Log.i(TAG, "capture stopped") Log.i(TAG, "capture stopped (frames=$frameIdx)")
} }
} }
@@ -181,24 +299,61 @@ class AudioPipeline(private val context: Context) {
Log.i(TAG, "playout started: ${SAMPLE_RATE}Hz mono, buf=$bufSize") Log.i(TAG, "playout started: ${SAMPLE_RATE}Hz mono, buf=$bufSize")
val pcm = ShortArray(FRAME_SAMPLES) val pcm = ShortArray(FRAME_SAMPLES)
val silence = ShortArray(FRAME_SAMPLES) // pre-allocated silence val silence = ShortArray(FRAME_SAMPLES)
// Debug: PCM file + RMS CSV for playout
var pcmOut: BufferedOutputStream? = null
var rmsCsv: OutputStreamWriter? = null
val byteConv = ByteBuffer.allocate(FRAME_SAMPLES * 2).order(ByteOrder.LITTLE_ENDIAN)
var frameIdx = 0L
if (debugRecording) {
try {
pcmOut = BufferedOutputStream(FileOutputStream(File(debugDir, "playout.pcm")), 65536)
rmsCsv = OutputStreamWriter(FileOutputStream(File(debugDir, "playout_rms.csv")))
rmsCsv.write("frame,time_ms,rms\n")
} catch (e: Exception) {
Log.w(TAG, "debug playout recording init failed: ${e.message}")
}
}
try { try {
while (running) { while (running) {
val read = engine.readAudio(pcm) // Zero-copy read via DirectByteBuffer (class field, survives JIT OSR)
playoutDirectBuf.clear()
val read = engine.readAudioDirect(playoutDirectBuf, FRAME_SAMPLES)
if (read >= FRAME_SAMPLES) { if (read >= FRAME_SAMPLES) {
playoutDirectBuf.rewind()
playoutDirectBuf.asShortBuffer().get(pcm, 0, read)
applyGain(pcm, read, playoutGainDb) applyGain(pcm, read, playoutGainDb)
track.write(pcm, 0, read) track.write(pcm, 0, read)
// Debug: write raw PCM + RMS
if (pcmOut != null) {
byteConv.clear()
for (i in 0 until read) byteConv.putShort(pcm[i])
pcmOut.write(byteConv.array(), 0, read * 2)
}
if (rmsCsv != null) {
val rms = computeRms(pcm, read)
val timeMs = frameIdx * FRAME_SAMPLES * 1000L / SAMPLE_RATE
rmsCsv.write("$frameIdx,$timeMs,$rms\n")
}
frameIdx++
} else { } else {
// Not enough decoded audio — write silence to keep stream alive
track.write(silence, 0, FRAME_SAMPLES) track.write(silence, 0, FRAME_SAMPLES)
// Sleep briefly to avoid busy-spinning // Log silence frames to RMS as 0
if (rmsCsv != null) {
val timeMs = frameIdx * FRAME_SAMPLES * 1000L / SAMPLE_RATE
rmsCsv.write("$frameIdx,$timeMs,0\n")
}
frameIdx++
Thread.sleep(5) Thread.sleep(5)
} }
} }
} finally { } finally {
pcmOut?.close()
rmsCsv?.close()
track.stop() track.stop()
track.release() track.release()
Log.i(TAG, "playout stopped") Log.i(TAG, "playout stopped (frames=$frameIdx)")
} }
} }
} }

View File

@@ -0,0 +1,182 @@
package com.wzp.data
import android.content.Context
import android.content.SharedPreferences
import com.wzp.ui.call.ServerEntry
import org.json.JSONArray
import org.json.JSONObject
import java.security.SecureRandom
/**
* Persists user settings via SharedPreferences.
*
* Stores: servers, default server index, room name, alias, gain values,
* IPv6 preference, and the identity seed (hex-encoded 32 bytes).
*/
class SettingsRepository(context: Context) {
private val prefs: SharedPreferences =
context.applicationContext.getSharedPreferences("wzp_settings", Context.MODE_PRIVATE)
companion object {
private const val KEY_SERVERS = "servers_json"
private const val KEY_SELECTED_SERVER = "selected_server"
private const val KEY_ROOM = "room_name"
private const val KEY_ALIAS = "alias"
private const val KEY_PLAYOUT_GAIN = "playout_gain_db"
private const val KEY_CAPTURE_GAIN = "capture_gain_db"
private const val KEY_PREFER_IPV6 = "prefer_ipv6"
private const val KEY_IDENTITY_SEED = "identity_seed_hex"
private const val KEY_AEC_ENABLED = "aec_enabled"
private const val KEY_RECENT_ROOMS = "recent_rooms"
private const val TOFU_PREFIX = "tofu_"
}
// --- Servers ---
fun saveServers(servers: List<ServerEntry>) {
val arr = JSONArray()
servers.forEach { entry ->
arr.put(JSONObject().apply {
put("address", entry.address)
put("label", entry.label)
})
}
prefs.edit().putString(KEY_SERVERS, arr.toString()).apply()
}
fun loadServers(): List<ServerEntry>? {
val json = prefs.getString(KEY_SERVERS, null) ?: return null
return try {
val arr = JSONArray(json)
(0 until arr.length()).map { i ->
val obj = arr.getJSONObject(i)
ServerEntry(obj.getString("address"), obj.getString("label"))
}
} catch (_: Exception) { null }
}
fun saveSelectedServer(index: Int) {
prefs.edit().putInt(KEY_SELECTED_SERVER, index).apply()
}
fun loadSelectedServer(): Int = prefs.getInt(KEY_SELECTED_SERVER, 0)
// --- Room ---
fun saveRoom(name: String) { prefs.edit().putString(KEY_ROOM, name).apply() }
fun loadRoom(): String = prefs.getString(KEY_ROOM, "android") ?: "android"
// --- Alias ---
fun saveAlias(alias: String) { prefs.edit().putString(KEY_ALIAS, alias).apply() }
/**
* Load alias, generating a random name on first launch.
*/
fun getOrCreateAlias(): String {
val existing = prefs.getString(KEY_ALIAS, null)
if (!existing.isNullOrEmpty()) return existing
val name = generateRandomName()
prefs.edit().putString(KEY_ALIAS, name).apply()
return name
}
private fun generateRandomName(): String {
val adjectives = listOf(
"Swift", "Silent", "Brave", "Calm", "Dark", "Fierce", "Ghost",
"Iron", "Lucky", "Noble", "Quick", "Sharp", "Storm", "Wild",
"Cold", "Bright", "Lone", "Red", "Grey", "Frosty", "Dusty",
"Rusty", "Neon", "Void", "Solar", "Lunar", "Cyber", "Pixel",
"Sonic", "Hyper", "Turbo", "Nano", "Mega", "Ultra", "Zinc"
)
val nouns = listOf(
"Wolf", "Hawk", "Fox", "Bear", "Lynx", "Crow", "Viper",
"Cobra", "Tiger", "Eagle", "Shark", "Raven", "Falcon", "Otter",
"Mantis", "Panda", "Jackal", "Badger", "Heron", "Bison",
"Condor", "Coyote", "Gecko", "Hornet", "Marten", "Osprey",
"Parrot", "Puma", "Raptor", "Stork", "Toucan", "Walrus"
)
val adj = adjectives.random()
val noun = nouns.random()
return "$adj $noun"
}
// --- Gain ---
fun savePlayoutGain(db: Float) { prefs.edit().putFloat(KEY_PLAYOUT_GAIN, db).apply() }
fun loadPlayoutGain(): Float = prefs.getFloat(KEY_PLAYOUT_GAIN, 0f)
fun saveCaptureGain(db: Float) { prefs.edit().putFloat(KEY_CAPTURE_GAIN, db).apply() }
fun loadCaptureGain(): Float = prefs.getFloat(KEY_CAPTURE_GAIN, 0f)
// --- IPv6 ---
fun savePreferIPv6(prefer: Boolean) { prefs.edit().putBoolean(KEY_PREFER_IPV6, prefer).apply() }
fun loadPreferIPv6(): Boolean = prefs.getBoolean(KEY_PREFER_IPV6, false)
// --- AEC ---
fun saveAecEnabled(enabled: Boolean) { prefs.edit().putBoolean(KEY_AEC_ENABLED, enabled).apply() }
fun loadAecEnabled(): Boolean = prefs.getBoolean(KEY_AEC_ENABLED, true)
// --- Identity seed ---
/**
* Get or generate the identity seed. On first call, generates a random
* 32-byte seed and persists it. Subsequent calls return the same seed.
*/
fun getOrCreateSeedHex(): String {
val existing = prefs.getString(KEY_IDENTITY_SEED, null)
if (!existing.isNullOrEmpty()) return existing
val seed = ByteArray(32).also { SecureRandom().nextBytes(it) }
val hex = seed.joinToString("") { "%02x".format(it) }
prefs.edit().putString(KEY_IDENTITY_SEED, hex).apply()
return hex
}
fun loadSeedHex(): String = prefs.getString(KEY_IDENTITY_SEED, "") ?: ""
fun saveSeedHex(hex: String) {
prefs.edit().putString(KEY_IDENTITY_SEED, hex).apply()
}
// --- Recent rooms ---
data class RecentRoom(val relay: String, val room: String)
fun addRecentRoom(relay: String, room: String) {
val rooms = loadRecentRooms().toMutableList()
rooms.removeAll { it.relay == relay && it.room == room }
rooms.add(0, RecentRoom(relay, room))
if (rooms.size > 5) rooms.subList(5, rooms.size).clear()
val arr = JSONArray()
rooms.forEach { arr.put(JSONObject().apply { put("relay", it.relay); put("room", it.room) }) }
prefs.edit().putString(KEY_RECENT_ROOMS, arr.toString()).apply()
}
fun loadRecentRooms(): List<RecentRoom> {
val json = prefs.getString(KEY_RECENT_ROOMS, null) ?: return emptyList()
return try {
val arr = JSONArray(json)
(0 until arr.length()).map { i ->
val o = arr.getJSONObject(i)
RecentRoom(o.getString("relay"), o.getString("room"))
}
} catch (_: Exception) { emptyList() }
}
fun clearRecentRooms() {
prefs.edit().remove(KEY_RECENT_ROOMS).apply()
}
// --- Server fingerprint TOFU ---
fun saveServerFingerprint(address: String, fingerprint: String) {
prefs.edit().putString("$TOFU_PREFIX$address", fingerprint).apply()
}
fun loadServerFingerprint(address: String): String? {
return prefs.getString("$TOFU_PREFIX$address", null)
}
}

View File

@@ -0,0 +1,198 @@
package com.wzp.debug
import android.content.Context
import android.util.Log
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import java.io.BufferedOutputStream
import java.io.ByteArrayOutputStream
import java.io.File
import java.io.FileInputStream
import java.io.FileOutputStream
import java.nio.ByteBuffer
import java.nio.ByteOrder
import java.text.SimpleDateFormat
import java.util.Date
import java.util.Locale
import java.util.zip.ZipEntry
import java.util.zip.ZipOutputStream
/**
* Collects call debug data (audio recordings, logs, histograms, stats)
* into a zip file for email sharing.
*/
class DebugReporter(private val context: Context) {
companion object {
private const val TAG = "DebugReporter"
private const val SAMPLE_RATE = 48000
}
/**
* Build a zip with all debug data.
* Returns the zip File on success, or null on failure.
*/
suspend fun collectZip(
callDurationSecs: Double,
finalStatsJson: String,
aecEnabled: Boolean,
alias: String,
server: String,
room: String
): File? = withContext(Dispatchers.IO) {
try {
val debugDir = File(context.cacheDir, "wzp_debug")
val timestamp = SimpleDateFormat("yyyyMMdd_HHmmss", Locale.US).format(Date())
val zipFile = File(context.cacheDir, "wzp_debug_${timestamp}.zip")
ZipOutputStream(BufferedOutputStream(FileOutputStream(zipFile))).use { zos ->
// 1. Call metadata
val meta = buildString {
appendLine("=== WZ Phone Debug Report ===")
appendLine("Timestamp: $timestamp")
appendLine("Alias: $alias")
appendLine("Server: $server")
appendLine("Room: $room")
appendLine("Duration: ${"%.1f".format(callDurationSecs)}s")
appendLine("AEC: ${if (aecEnabled) "ON" else "OFF"}")
appendLine("Device: ${android.os.Build.MANUFACTURER} ${android.os.Build.MODEL}")
appendLine("Android: ${android.os.Build.VERSION.RELEASE} (API ${android.os.Build.VERSION.SDK_INT})")
appendLine()
appendLine("=== Final Stats ===")
appendLine(finalStatsJson)
}
addTextEntry(zos, "meta.txt", meta)
// 2. Logcat — WZP-related tags
val logcat = collectLogcat()
addTextEntry(zos, "logcat.txt", logcat)
// 3. Capture audio (mic) → WAV
val captureRaw = File(debugDir, "capture.pcm")
if (captureRaw.exists() && captureRaw.length() > 0) {
addWavEntry(zos, "capture.wav", captureRaw)
Log.i(TAG, "capture.pcm: ${captureRaw.length()} bytes -> WAV")
}
// 4. Playout audio (speaker) → WAV
val playoutRaw = File(debugDir, "playout.pcm")
if (playoutRaw.exists() && playoutRaw.length() > 0) {
addWavEntry(zos, "playout.wav", playoutRaw)
Log.i(TAG, "playout.pcm: ${playoutRaw.length()} bytes -> WAV")
}
// 5. RMS histogram CSV
val captureHist = File(debugDir, "capture_rms.csv")
if (captureHist.exists()) addFileEntry(zos, "capture_rms.csv", captureHist)
val playoutHist = File(debugDir, "playout_rms.csv")
if (playoutHist.exists()) addFileEntry(zos, "playout_rms.csv", playoutHist)
}
Log.i(TAG, "zip created: ${zipFile.length()} bytes (${zipFile.length() / 1024}KB)")
// Clean up raw debug files (keep zip)
debugDir.listFiles()?.forEach { it.delete() }
zipFile
} catch (e: Exception) {
Log.e(TAG, "debug report failed", e)
null
}
}
/** Clean up any leftover debug files from a previous session. */
fun prepareForCall() {
val debugDir = File(context.cacheDir, "wzp_debug")
if (debugDir.exists()) {
debugDir.listFiles()?.forEach { it.delete() }
}
debugDir.mkdirs()
// Also clean up old zip files
context.cacheDir.listFiles()?.filter { it.name.startsWith("wzp_debug_") }?.forEach { it.delete() }
}
private fun collectLogcat(): String {
return try {
val process = Runtime.getRuntime().exec(
arrayOf(
"logcat", "-d",
"-t", "5000",
"--format", "threadtime"
)
)
val output = process.inputStream.bufferedReader().readText()
process.waitFor()
output.lines()
.filter { line ->
line.contains("wzp", ignoreCase = true) ||
line.contains("WzpEngine") ||
line.contains("AudioPipeline") ||
line.contains("WzpCall") ||
line.contains("CallService") ||
line.contains("AudioTrack") ||
line.contains("AudioRecord") ||
line.contains("AcousticEchoCanceler") ||
line.contains("NoiseSuppressor") ||
line.contains("FATAL") ||
line.contains("ANR") ||
line.contains("AudioFlinger") ||
line.contains("DebugReporter") ||
line.contains("QUIC") ||
line.contains("quinn") ||
line.contains("send task") ||
line.contains("recv task") ||
line.contains("send stats") ||
line.contains("recv stats") ||
line.contains("send_media") ||
line.contains("FEC block") ||
line.contains("recv gap") ||
line.contains("frames_dropped") ||
line.contains("opus")
}
.joinToString("\n")
} catch (e: Exception) {
"Failed to collect logcat: ${e.message}"
}
}
private fun addWavEntry(zos: ZipOutputStream, name: String, pcmFile: File) {
val dataSize = pcmFile.length().toInt()
val byteRate = SAMPLE_RATE * 1 * 16 / 8
val blockAlign = 1 * 16 / 8
zos.putNextEntry(ZipEntry(name))
// Write WAV header (44 bytes)
val header = ByteBuffer.allocate(44).order(ByteOrder.LITTLE_ENDIAN)
header.put("RIFF".toByteArray())
header.putInt(36 + dataSize)
header.put("WAVE".toByteArray())
header.put("fmt ".toByteArray())
header.putInt(16)
header.putShort(1) // PCM
header.putShort(1) // mono
header.putInt(SAMPLE_RATE)
header.putInt(byteRate)
header.putShort(blockAlign.toShort())
header.putShort(16) // bits per sample
header.put("data".toByteArray())
header.putInt(dataSize)
zos.write(header.array())
// Stream PCM data directly (avoids loading entire file into memory)
FileInputStream(pcmFile).use { it.copyTo(zos) }
zos.closeEntry()
}
private fun addTextEntry(zos: ZipOutputStream, name: String, content: String) {
zos.putNextEntry(ZipEntry(name))
zos.write(content.toByteArray())
zos.closeEntry()
}
private fun addFileEntry(zos: ZipOutputStream, name: String, file: File) {
zos.putNextEntry(ZipEntry(name))
FileInputStream(file).use { it.copyTo(zos) }
zos.closeEntry()
}
}

View File

@@ -54,7 +54,7 @@ data class CallStats(
val o = arr.getJSONObject(i) val o = arr.getJSONObject(i)
RoomMember( RoomMember(
fingerprint = o.optString("fingerprint", ""), fingerprint = o.optString("fingerprint", ""),
alias = o.optString("alias", null) alias = if (o.isNull("alias")) null else o.optString("alias", null)
) )
} }
} }
@@ -92,5 +92,6 @@ data class RoomMember(
) { ) {
/** Short display name: alias if set, otherwise first 8 chars of fingerprint. */ /** Short display name: alias if set, otherwise first 8 chars of fingerprint. */
val displayName: String val displayName: String
get() = alias ?: fingerprint.take(8) get() = alias?.takeIf { it.isNotBlank() }
?: fingerprint.take(8).ifEmpty { "unknown" }
} }

View File

@@ -35,11 +35,12 @@ class WzpEngine(private val callback: WzpCallback) {
* @param room room identifier (used as QUIC SNI) * @param room room identifier (used as QUIC SNI)
* @param seedHex 64-char hex-encoded 32-byte identity seed (empty = random) * @param seedHex 64-char hex-encoded 32-byte identity seed (empty = random)
* @param token authentication token (empty = no auth) * @param token authentication token (empty = no auth)
* @param alias display name sent to relay for room participant list
* @return 0 on success, negative error code on failure * @return 0 on success, negative error code on failure
*/ */
fun startCall(relayAddr: String, room: String, seedHex: String = "", token: String = ""): Int { fun startCall(relayAddr: String, room: String, seedHex: String = "", token: String = "", alias: String = ""): Int {
check(nativeHandle != 0L) { "Engine not initialized" } check(nativeHandle != 0L) { "Engine not initialized" }
val result = nativeStartCall(nativeHandle, relayAddr, room, seedHex, token) val result = nativeStartCall(nativeHandle, relayAddr, room, seedHex, token, alias)
if (result == 0) { if (result == 0) {
callback.onCallStateChanged(CallStateConstants.CONNECTING) callback.onCallStateChanged(CallStateConstants.CONNECTING)
} else { } else {
@@ -116,11 +117,31 @@ class WzpEngine(private val callback: WzpCallback) {
return nativeReadAudio(nativeHandle, pcm) return nativeReadAudio(nativeHandle, pcm)
} }
/**
* Write captured PCM from a DirectByteBuffer — zero JNI array copy.
* The buffer must be a direct ByteBuffer with native byte order containing i16 samples.
* Called from the AudioRecord capture thread.
*/
fun writeAudioDirect(buffer: java.nio.ByteBuffer, sampleCount: Int): Int {
if (nativeHandle == 0L) return 0
return nativeWriteAudioDirect(nativeHandle, buffer, sampleCount)
}
/**
* Read decoded PCM into a DirectByteBuffer — zero JNI array copy.
* The buffer must be a direct ByteBuffer with native byte order.
* Called from the AudioTrack playout thread.
*/
fun readAudioDirect(buffer: java.nio.ByteBuffer, maxSamples: Int): Int {
if (nativeHandle == 0L) return 0
return nativeReadAudioDirect(nativeHandle, buffer, maxSamples)
}
// -- JNI native methods -------------------------------------------------- // -- JNI native methods --------------------------------------------------
private external fun nativeInit(): Long private external fun nativeInit(): Long
private external fun nativeStartCall( private external fun nativeStartCall(
handle: Long, relay: String, room: String, seed: String, token: String handle: Long, relay: String, room: String, seed: String, token: String, alias: String
): Int ): Int
private external fun nativeStopCall(handle: Long) private external fun nativeStopCall(handle: Long)
private external fun nativeSetMute(handle: Long, muted: Boolean) private external fun nativeSetMute(handle: Long, muted: Boolean)
@@ -129,12 +150,23 @@ class WzpEngine(private val callback: WzpCallback) {
private external fun nativeForceProfile(handle: Long, profile: Int) private external fun nativeForceProfile(handle: Long, profile: Int)
private external fun nativeWriteAudio(handle: Long, pcm: ShortArray): Int private external fun nativeWriteAudio(handle: Long, pcm: ShortArray): Int
private external fun nativeReadAudio(handle: Long, pcm: ShortArray): Int private external fun nativeReadAudio(handle: Long, pcm: ShortArray): Int
private external fun nativeWriteAudioDirect(handle: Long, buffer: java.nio.ByteBuffer, sampleCount: Int): Int
private external fun nativeReadAudioDirect(handle: Long, buffer: java.nio.ByteBuffer, maxSamples: Int): Int
private external fun nativeDestroy(handle: Long) private external fun nativeDestroy(handle: Long)
companion object { companion object {
init { init {
System.loadLibrary("wzp_android") System.loadLibrary("wzp_android")
} }
/**
* Ping a relay server. Returns JSON `{"rtt_ms":N,"server_fingerprint":"hex"}`
* or null if unreachable. Does not require an engine instance.
*/
fun pingRelay(address: String): String? = nativePingRelay(address)
@JvmStatic
private external fun nativePingRelay(relay: String): String?
} }
} }

View File

@@ -1,8 +1,10 @@
package com.wzp.ui.call package com.wzp.ui.call
import android.Manifest import android.Manifest
import android.content.Intent
import android.content.pm.PackageManager import android.content.pm.PackageManager
import android.os.Bundle import android.os.Bundle
import android.util.Log
import android.widget.Toast import android.widget.Toast
import androidx.activity.ComponentActivity import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent import androidx.activity.compose.setContent
@@ -15,8 +17,18 @@ import androidx.compose.material3.dynamicLightColorScheme
import androidx.compose.material3.lightColorScheme import androidx.compose.material3.lightColorScheme
import androidx.compose.foundation.isSystemInDarkTheme import androidx.compose.foundation.isSystemInDarkTheme
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalContext
import androidx.core.content.ContextCompat import androidx.core.content.ContextCompat
import androidx.core.content.FileProvider
import androidx.lifecycle.Lifecycle
import androidx.lifecycle.lifecycleScope
import androidx.lifecycle.repeatOnLifecycle
import com.wzp.ui.settings.SettingsScreen
import kotlinx.coroutines.launch
/** /**
* Main activity hosting the in-call Compose UI. * Main activity hosting the in-call Compose UI.
@@ -26,6 +38,10 @@ import androidx.core.content.ContextCompat
*/ */
class CallActivity : ComponentActivity() { class CallActivity : ComponentActivity() {
companion object {
private const val TAG = "CallActivity"
}
private val viewModel: CallViewModel by viewModels() private val viewModel: CallViewModel by viewModels()
private val audioPermissionLauncher = registerForActivityResult( private val audioPermissionLauncher = registerForActivityResult(
@@ -43,12 +59,19 @@ class CallActivity : ComponentActivity() {
setContent { setContent {
WzpTheme { WzpTheme {
InCallScreen( var showSettings by remember { mutableStateOf(false) }
viewModel = viewModel, if (showSettings) {
onHangUp = { SettingsScreen(
viewModel.stopCall() viewModel = viewModel,
} onBack = { showSettings = false }
) )
} else {
InCallScreen(
viewModel = viewModel,
onHangUp = { viewModel.stopCall() },
onOpenSettings = { showSettings = true }
)
}
} }
} }
@@ -57,6 +80,45 @@ class CallActivity : ComponentActivity() {
) { ) {
audioPermissionLauncher.launch(Manifest.permission.RECORD_AUDIO) audioPermissionLauncher.launch(Manifest.permission.RECORD_AUDIO)
} }
// Watch for debug zip ready → launch email intent
lifecycleScope.launch {
repeatOnLifecycle(Lifecycle.State.STARTED) {
viewModel.debugZipReady.collect { zipFile ->
if (zipFile != null && zipFile.exists()) {
Log.i(TAG, "debug zip ready: ${zipFile.absolutePath} (${zipFile.length()} bytes)")
launchEmailIntent(zipFile)
viewModel.onDebugReportSent()
}
}
}
}
}
private fun launchEmailIntent(zipFile: java.io.File) {
try {
val authority = "${applicationContext.packageName}.fileprovider"
Log.i(TAG, "FileProvider authority: $authority, file: ${zipFile.absolutePath}")
val uri = FileProvider.getUriForFile(this, authority, zipFile)
Log.i(TAG, "FileProvider URI: $uri")
val intent = Intent(Intent.ACTION_SEND).apply {
type = "message/rfc822"
putExtra(Intent.EXTRA_EMAIL, arrayOf("manwefarm@gmail.com"))
putExtra(Intent.EXTRA_SUBJECT, "WZ Phone Debug Report - ${zipFile.name}")
putExtra(
Intent.EXTRA_TEXT,
"Debug report attached.\n\nContains: call recordings (WAV), RMS histograms (CSV), logcat, stats."
)
putExtra(Intent.EXTRA_STREAM, uri)
addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION)
}
startActivity(Intent.createChooser(intent, "Send debug report"))
Log.i(TAG, "email intent launched")
} catch (e: Exception) {
Log.e(TAG, "email intent failed", e)
Toast.makeText(this, "Failed to launch email: ${e.message}", Toast.LENGTH_LONG).show()
}
} }
override fun onDestroy() { override fun onDestroy() {

View File

@@ -6,10 +6,13 @@ import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope import androidx.lifecycle.viewModelScope
import com.wzp.audio.AudioPipeline import com.wzp.audio.AudioPipeline
import com.wzp.audio.AudioRouteManager import com.wzp.audio.AudioRouteManager
import com.wzp.data.SettingsRepository
import com.wzp.debug.DebugReporter
import com.wzp.engine.CallStats import com.wzp.engine.CallStats
import com.wzp.service.CallService import com.wzp.service.CallService
import com.wzp.engine.WzpCallback import com.wzp.engine.WzpCallback
import com.wzp.engine.WzpEngine import com.wzp.engine.WzpEngine
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job import kotlinx.coroutines.Job
import kotlinx.coroutines.delay import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
@@ -17,12 +20,22 @@ import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.isActive import kotlinx.coroutines.isActive
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import org.json.JSONObject
import java.io.File
import java.net.Inet4Address import java.net.Inet4Address
import java.net.Inet6Address import java.net.Inet6Address
import java.net.InetAddress import java.net.InetAddress
data class ServerEntry(val address: String, val label: String) data class ServerEntry(val address: String, val label: String)
data class PingResult(
val rttMs: Int,
val serverFingerprint: String,
)
enum class LockStatus { UNKNOWN, OFFLINE, NEW, VERIFIED, CHANGED }
class CallViewModel : ViewModel(), WzpCallback { class CallViewModel : ViewModel(), WzpCallback {
private var engine: WzpEngine? = null private var engine: WzpEngine? = null
@@ -31,6 +44,11 @@ class CallViewModel : ViewModel(), WzpCallback {
private var audioRouteManager: AudioRouteManager? = null private var audioRouteManager: AudioRouteManager? = null
private var audioStarted = false private var audioStarted = false
private var appContext: Context? = null private var appContext: Context? = null
private var settings: SettingsRepository? = null
private var debugReporter: DebugReporter? = null
private var lastStatsJson: String = "{}"
private var lastCallDuration: Double = 0.0
private var lastCallServer: String = ""
private val _callState = MutableStateFlow(0) private val _callState = MutableStateFlow(0)
val callState: StateFlow<Int> get() = _callState.asStateFlow() val callState: StateFlow<Int> get() = _callState.asStateFlow()
@@ -62,12 +80,43 @@ class CallViewModel : ViewModel(), WzpCallback {
private val _preferIPv6 = MutableStateFlow(false) private val _preferIPv6 = MutableStateFlow(false)
val preferIPv6: StateFlow<Boolean> = _preferIPv6.asStateFlow() val preferIPv6: StateFlow<Boolean> = _preferIPv6.asStateFlow()
private val _recentRooms = MutableStateFlow<List<com.wzp.data.SettingsRepository.RecentRoom>>(emptyList())
val recentRooms: StateFlow<List<com.wzp.data.SettingsRepository.RecentRoom>> = _recentRooms.asStateFlow()
/** Ping results keyed by server address. */
private val _pingResults = MutableStateFlow<Map<String, PingResult>>(emptyMap())
val pingResults: StateFlow<Map<String, PingResult>> = _pingResults.asStateFlow()
/** Known server fingerprints (TOFU). */
private val _knownFingerprints = MutableStateFlow<Map<String, String>>(emptyMap())
private val _playoutGainDb = MutableStateFlow(0f) private val _playoutGainDb = MutableStateFlow(0f)
val playoutGainDb: StateFlow<Float> = _playoutGainDb.asStateFlow() val playoutGainDb: StateFlow<Float> = _playoutGainDb.asStateFlow()
private val _captureGainDb = MutableStateFlow(0f) private val _captureGainDb = MutableStateFlow(0f)
val captureGainDb: StateFlow<Float> = _captureGainDb.asStateFlow() val captureGainDb: StateFlow<Float> = _captureGainDb.asStateFlow()
private val _alias = MutableStateFlow("")
val alias: StateFlow<String> = _alias.asStateFlow()
private val _seedHex = MutableStateFlow("")
val seedHex: StateFlow<String> = _seedHex.asStateFlow()
private val _aecEnabled = MutableStateFlow(true)
val aecEnabled: StateFlow<Boolean> = _aecEnabled.asStateFlow()
/** True when a call just ended and debug report can be sent. */
private val _debugReportAvailable = MutableStateFlow(false)
val debugReportAvailable: StateFlow<Boolean> = _debugReportAvailable.asStateFlow()
/** Status: null=idle, "Preparing..."=in progress, "ready"=zip ready, "Error:..."=failed */
private val _debugReportStatus = MutableStateFlow<String?>(null)
val debugReportStatus: StateFlow<String?> = _debugReportStatus.asStateFlow()
/** The zip file ready to be emailed. Set by sendDebugReport, consumed by Activity. */
private val _debugZipReady = MutableStateFlow<File?>(null)
val debugZipReady: StateFlow<File?> = _debugZipReady.asStateFlow()
private var statsJob: Job? = null private var statsJob: Job? = null
companion object { companion object {
@@ -88,20 +137,48 @@ class CallViewModel : ViewModel(), WzpCallback {
if (audioRouteManager == null) { if (audioRouteManager == null) {
audioRouteManager = AudioRouteManager(appCtx) audioRouteManager = AudioRouteManager(appCtx)
} }
if (debugReporter == null) {
debugReporter = DebugReporter(appCtx)
}
if (settings == null) {
settings = SettingsRepository(appCtx)
loadSettings()
}
}
private fun loadSettings() {
val s = settings ?: return
s.loadServers()?.let { saved ->
if (saved.isNotEmpty()) _servers.value = saved
}
_selectedServer.value = s.loadSelectedServer().coerceIn(0, _servers.value.lastIndex)
_roomName.value = s.loadRoom()
_alias.value = s.getOrCreateAlias()
_preferIPv6.value = s.loadPreferIPv6()
_playoutGainDb.value = s.loadPlayoutGain()
_captureGainDb.value = s.loadCaptureGain()
_seedHex.value = s.getOrCreateSeedHex()
_aecEnabled.value = s.loadAecEnabled()
_recentRooms.value = s.loadRecentRooms()
} }
fun selectServer(index: Int) { fun selectServer(index: Int) {
if (index in _servers.value.indices) { if (index in _servers.value.indices) {
_selectedServer.value = index _selectedServer.value = index
settings?.saveSelectedServer(index)
} }
} }
fun setPreferIPv6(prefer: Boolean) { _preferIPv6.value = prefer } fun setPreferIPv6(prefer: Boolean) {
_preferIPv6.value = prefer
settings?.savePreferIPv6(prefer)
}
fun addServer(hostPort: String, label: String) { fun addServer(hostPort: String, label: String) {
val current = _servers.value.toMutableList() val current = _servers.value.toMutableList()
current.add(ServerEntry(hostPort, label)) current.add(ServerEntry(hostPort, label))
_servers.value = current _servers.value = current
settings?.saveServers(current)
} }
fun removeServer(index: Int) { fun removeServer(index: Int) {
@@ -113,19 +190,94 @@ class CallViewModel : ViewModel(), WzpCallback {
if (_selectedServer.value >= current.size) { if (_selectedServer.value >= current.size) {
_selectedServer.value = 0 _selectedServer.value = 0
} }
settings?.saveServers(current)
settings?.saveSelectedServer(_selectedServer.value)
} }
} }
fun setRoomName(name: String) { _roomName.value = name } /** Batch-apply servers and selection from Settings draft state. */
fun applyServers(servers: List<ServerEntry>, selected: Int) {
_servers.value = servers
_selectedServer.value = selected.coerceIn(0, servers.lastIndex)
settings?.saveServers(servers)
settings?.saveSelectedServer(_selectedServer.value)
}
/** Ping all servers in background, update results. */
fun pingAllServers() {
viewModelScope.launch {
val results = mutableMapOf<String, PingResult>()
val known = mutableMapOf<String, String>()
_servers.value.forEach { server ->
val pr = withContext(Dispatchers.IO) {
try {
val json = WzpEngine.pingRelay(server.address) ?: return@withContext null
val obj = JSONObject(json)
PingResult(
rttMs = obj.getInt("rtt_ms"),
serverFingerprint = obj.optString("server_fingerprint", ""),
)
} catch (e: Exception) {
Log.w(TAG, "ping ${server.address} failed: ${e.message}")
null
}
}
if (pr != null) {
results[server.address] = pr
// TOFU: save fingerprint on first contact
if (pr.serverFingerprint.isNotEmpty()) {
val saved = settings?.loadServerFingerprint(server.address)
if (saved == null) {
settings?.saveServerFingerprint(server.address, pr.serverFingerprint)
}
known[server.address] = saved ?: pr.serverFingerprint
}
}
}
_pingResults.value = results
_knownFingerprints.value = known
}
}
/** Get lock status for a server. */
fun lockStatus(address: String): LockStatus {
val pr = _pingResults.value[address] ?: return LockStatus.UNKNOWN
val known = _knownFingerprints.value[address]
if (pr.serverFingerprint.isEmpty()) return LockStatus.NEW
if (known == null) return LockStatus.NEW
return if (pr.serverFingerprint == known) LockStatus.VERIFIED else LockStatus.CHANGED
}
fun setRoomName(name: String) {
_roomName.value = name
settings?.saveRoom(name)
}
fun setPlayoutGainDb(db: Float) { fun setPlayoutGainDb(db: Float) {
_playoutGainDb.value = db _playoutGainDb.value = db
audioPipeline?.playoutGainDb = db audioPipeline?.playoutGainDb = db
settings?.savePlayoutGain(db)
} }
fun setCaptureGainDb(db: Float) { fun setCaptureGainDb(db: Float) {
_captureGainDb.value = db _captureGainDb.value = db
audioPipeline?.captureGainDb = db audioPipeline?.captureGainDb = db
settings?.saveCaptureGain(db)
}
fun setAlias(alias: String) {
_alias.value = alias
settings?.saveAlias(alias)
}
fun restoreSeed(hex: String) {
_seedHex.value = hex
settings?.saveSeedHex(hex)
}
fun setAecEnabled(enabled: Boolean) {
_aecEnabled.value = enabled
settings?.saveAecEnabled(enabled)
} }
/** /**
@@ -166,15 +318,28 @@ class CallViewModel : ViewModel(), WzpCallback {
/** Tear down engine and audio. Pass stopService=true to also stop the foreground service. */ /** Tear down engine and audio. Pass stopService=true to also stop the foreground service. */
private fun teardown(stopService: Boolean = true) { private fun teardown(stopService: Boolean = true) {
Log.i(TAG, "teardown: stopping audio, stopService=$stopService") Log.i(TAG, "teardown: stopping audio, stopService=$stopService")
val hadCall = audioStarted
CallService.onStopFromNotification = null CallService.onStopFromNotification = null
stopAudio() stopAudio() // sets running=false (non-blocking)
stopStatsPolling() stopStatsPolling()
// Wait for audio threads to exit their loops before destroying the engine.
// This guarantees no in-flight JNI calls to writeAudio/readAudio.
val drained = audioPipeline?.awaitDrain() ?: true
if (!drained) {
Log.w(TAG, "teardown: audio threads did not drain in time")
}
audioPipeline = null
Log.i(TAG, "teardown: stopping engine") Log.i(TAG, "teardown: stopping engine")
try { engine?.stopCall() } catch (e: Exception) { Log.w(TAG, "stopCall err: $e") } try { engine?.stopCall() } catch (e: Exception) { Log.w(TAG, "stopCall err: $e") }
try { engine?.destroy() } catch (e: Exception) { Log.w(TAG, "destroy err: $e") } try { engine?.destroy() } catch (e: Exception) { Log.w(TAG, "destroy err: $e") }
engine = null engine = null
engineInitialized = false engineInitialized = false
_callState.value = 0 _callState.value = 0
if (hadCall) {
_debugReportAvailable.value = true
}
if (stopService) { if (stopService) {
try { appContext?.let { CallService.stop(it) } } catch (_: Exception) {} try { appContext?.let { CallService.stop(it) } } catch (_: Exception) {}
} }
@@ -185,6 +350,12 @@ class CallViewModel : ViewModel(), WzpCallback {
val serverEntry = _servers.value[_selectedServer.value] val serverEntry = _servers.value[_selectedServer.value]
val room = _roomName.value val room = _roomName.value
Log.i(TAG, "startCall: server=${serverEntry.address} room=$room") Log.i(TAG, "startCall: server=${serverEntry.address} room=$room")
_debugReportAvailable.value = false
_debugReportStatus.value = null
lastCallServer = serverEntry.address
settings?.addRecentRoom(serverEntry.address, room)
_recentRooms.value = settings?.loadRecentRooms() ?: emptyList()
debugReporter?.prepareForCall()
try { try {
// Teardown previous call but don't stop the service (we're about to restart it) // Teardown previous call but don't stop the service (we're about to restart it)
teardown(stopService = false) teardown(stopService = false)
@@ -203,8 +374,10 @@ class CallViewModel : ViewModel(), WzpCallback {
viewModelScope.launch(kotlinx.coroutines.Dispatchers.IO) { viewModelScope.launch(kotlinx.coroutines.Dispatchers.IO) {
try { try {
val relay = resolveToIp(serverEntry.address) val relay = resolveToIp(serverEntry.address)
Log.i(TAG, "startCall: resolved=$relay, calling engine.startCall") val seed = _seedHex.value
val result = engine?.startCall(relay, room) ?: -1 val name = _alias.value
Log.i(TAG, "startCall: resolved=$relay, alias=$name, calling engine.startCall")
val result = engine?.startCall(relay, room, seedHex = seed, alias = name) ?: -1
Log.i(TAG, "startCall: engine returned $result") Log.i(TAG, "startCall: engine returned $result")
// Only wire up notification callback after engine is running // Only wire up notification callback after engine is running
CallService.onStopFromNotification = { stopCall() } CallService.onStopFromNotification = { stopCall() }
@@ -247,6 +420,40 @@ class CallViewModel : ViewModel(), WzpCallback {
fun clearError() { _errorMessage.value = null } fun clearError() { _errorMessage.value = null }
fun sendDebugReport() {
val reporter = debugReporter ?: return
_debugReportStatus.value = "Preparing debug report..."
viewModelScope.launch(kotlinx.coroutines.Dispatchers.IO) {
val zipFile = reporter.collectZip(
callDurationSecs = lastCallDuration,
finalStatsJson = lastStatsJson,
aecEnabled = _aecEnabled.value,
alias = _alias.value,
server = lastCallServer,
room = _roomName.value
)
if (zipFile != null) {
_debugZipReady.value = zipFile
_debugReportStatus.value = "ready"
} else {
_debugReportStatus.value = "Error: failed to create zip"
}
_debugReportAvailable.value = false
}
}
/** Called by Activity after email intent is launched. */
fun onDebugReportSent() {
_debugZipReady.value = null
_debugReportStatus.value = null
}
fun dismissDebugReport() {
_debugReportAvailable.value = false
_debugReportStatus.value = null
_debugZipReady.value = null
}
// WzpCallback // WzpCallback
override fun onCallStateChanged(state: Int) { _callState.value = state } override fun onCallStateChanged(state: Int) { _callState.value = state }
override fun onQualityTierChanged(tier: Int) { _qualityTier.value = tier } override fun onQualityTierChanged(tier: Int) { _qualityTier.value = tier }
@@ -260,6 +467,7 @@ class CallViewModel : ViewModel(), WzpCallback {
audioPipeline = AudioPipeline(ctx).also { audioPipeline = AudioPipeline(ctx).also {
it.playoutGainDb = _playoutGainDb.value it.playoutGainDb = _playoutGainDb.value
it.captureGainDb = _captureGainDb.value it.captureGainDb = _captureGainDb.value
it.aecEnabled = _aecEnabled.value
it.start(e) it.start(e)
} }
audioRouteManager?.register() audioRouteManager?.register()
@@ -268,8 +476,7 @@ class CallViewModel : ViewModel(), WzpCallback {
private fun stopAudio() { private fun stopAudio() {
if (!audioStarted) return if (!audioStarted) return
audioPipeline?.stop() audioPipeline?.stop() // sets running=false; DON'T null — teardown needs awaitDrain()
audioPipeline = null
audioRouteManager?.unregister() audioRouteManager?.unregister()
audioRouteManager?.setSpeaker(false) audioRouteManager?.setSpeaker(false)
_isSpeaker.value = false _isSpeaker.value = false
@@ -284,7 +491,9 @@ class CallViewModel : ViewModel(), WzpCallback {
val json = engine?.getStats() ?: "{}" val json = engine?.getStats() ?: "{}"
if (json.isNotEmpty()) { if (json.isNotEmpty()) {
Log.d(TAG, "raw: $json") Log.d(TAG, "raw: $json")
lastStatsJson = json
val s = CallStats.fromJson(json) val s = CallStats.fromJson(json)
lastCallDuration = s.durationSecs
_stats.value = s _stats.value = s
if (s.state != 0) { if (s.state != 0) {
_callState.value = s.state _callState.value = s.state

View File

@@ -24,7 +24,6 @@ import androidx.compose.material3.ButtonDefaults
import androidx.compose.material3.FilledIconButton import androidx.compose.material3.FilledIconButton
import androidx.compose.material3.FilledTonalIconButton import androidx.compose.material3.FilledTonalIconButton
import androidx.compose.material3.IconButtonDefaults import androidx.compose.material3.IconButtonDefaults
import androidx.compose.material3.LinearProgressIndicator
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedButton import androidx.compose.material3.OutlinedButton
import androidx.compose.material3.OutlinedTextField import androidx.compose.material3.OutlinedTextField
@@ -43,18 +42,21 @@ import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Color
import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp import androidx.compose.ui.unit.sp
import com.wzp.engine.CallStats import com.wzp.engine.CallStats
import com.wzp.ui.call.LockStatus
import kotlin.math.roundToInt import kotlin.math.roundToInt
@OptIn(ExperimentalLayoutApi::class) @OptIn(ExperimentalLayoutApi::class)
@Composable @Composable
fun InCallScreen( fun InCallScreen(
viewModel: CallViewModel, viewModel: CallViewModel,
onHangUp: () -> Unit onHangUp: () -> Unit,
onOpenSettings: () -> Unit = {}
) { ) {
val callState by viewModel.callState.collectAsState() val callState by viewModel.callState.collectAsState()
val isMuted by viewModel.isMuted.collectAsState() val isMuted by viewModel.isMuted.collectAsState()
@@ -68,6 +70,8 @@ fun InCallScreen(
val preferIPv6 by viewModel.preferIPv6.collectAsState() val preferIPv6 by viewModel.preferIPv6.collectAsState()
val playoutGainDb by viewModel.playoutGainDb.collectAsState() val playoutGainDb by viewModel.playoutGainDb.collectAsState()
val captureGainDb by viewModel.captureGainDb.collectAsState() val captureGainDb by viewModel.captureGainDb.collectAsState()
val debugReportAvailable by viewModel.debugReportAvailable.collectAsState()
val debugReportStatus by viewModel.debugReportStatus.collectAsState()
var showAddServerDialog by remember { mutableStateOf(false) } var showAddServerDialog by remember { mutableStateOf(false) }
@@ -82,7 +86,16 @@ fun InCallScreen(
.verticalScroll(rememberScrollState()), .verticalScroll(rememberScrollState()),
horizontalAlignment = Alignment.CenterHorizontally horizontalAlignment = Alignment.CenterHorizontally
) { ) {
Spacer(modifier = Modifier.height(48.dp)) // Settings button (top-right)
if (callState == 0) {
Row(modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.End) {
TextButton(onClick = onOpenSettings) {
Text("Settings")
}
}
}
Spacer(modifier = Modifier.height(if (callState == 0) 16.dp else 48.dp))
Text( Text(
text = "WZ Phone", text = "WZ Phone",
@@ -106,18 +119,31 @@ fun InCallScreen(
color = MaterialTheme.colorScheme.onSurfaceVariant color = MaterialTheme.colorScheme.onSurfaceVariant
) )
Spacer(modifier = Modifier.height(4.dp)) Spacer(modifier = Modifier.height(4.dp))
val pingResults by viewModel.pingResults.collectAsState()
FlowRow( FlowRow(
modifier = Modifier.fillMaxWidth(), modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.Center horizontalArrangement = Arrangement.Center
) { ) {
servers.forEachIndexed { idx, entry -> servers.forEachIndexed { idx, entry ->
val isSelected = selectedServer == idx val isSelected = selectedServer == idx
val ping = pingResults[entry.address]
val lockStatus = viewModel.lockStatus(entry.address)
val lockIcon = when (lockStatus) {
LockStatus.VERIFIED -> "\uD83D\uDD12" // 🔒
LockStatus.NEW -> "\uD83D\uDD13" // 🔓
LockStatus.CHANGED -> "\uFE0F" // ⚠️
LockStatus.OFFLINE -> "\uD83D\uDD34" // 🔴
LockStatus.UNKNOWN -> ""
}
val rttText = ping?.let { "${it.rttMs}ms" } ?: ""
FilledTonalIconButton( FilledTonalIconButton(
onClick = { viewModel.selectServer(idx) }, onClick = { viewModel.selectServer(idx) },
modifier = Modifier modifier = Modifier
.padding(2.dp) .padding(2.dp)
.height(36.dp) .height(40.dp)
.width(140.dp), .width(160.dp),
shape = RoundedCornerShape(8.dp), shape = RoundedCornerShape(8.dp),
colors = if (isSelected) { colors = if (isSelected) {
IconButtonDefaults.filledTonalIconButtonColors( IconButtonDefaults.filledTonalIconButtonColors(
@@ -128,11 +154,28 @@ fun InCallScreen(
IconButtonDefaults.filledTonalIconButtonColors() IconButtonDefaults.filledTonalIconButtonColors()
} }
) { ) {
Text( Row(verticalAlignment = Alignment.CenterVertically) {
text = entry.label, if (lockIcon.isNotEmpty()) {
style = MaterialTheme.typography.labelSmall, Text(text = lockIcon, fontSize = 12.sp)
maxLines = 1 Spacer(modifier = Modifier.width(4.dp))
) }
Text(
text = entry.label,
style = MaterialTheme.typography.labelSmall,
maxLines = 1
)
if (rttText.isNotEmpty()) {
Spacer(modifier = Modifier.width(4.dp))
Text(
text = rttText,
style = MaterialTheme.typography.labelSmall.copy(fontSize = 9.sp),
color = when {
(ping?.rttMs ?: 0) > 200 -> Color(0xFFFACC15) // yellow
else -> Color(0xFF4ADE80) // green
}
)
}
}
} }
} }
// + Add button // + Add button
@@ -140,13 +183,18 @@ fun InCallScreen(
onClick = { showAddServerDialog = true }, onClick = { showAddServerDialog = true },
modifier = Modifier modifier = Modifier
.padding(2.dp) .padding(2.dp)
.height(36.dp), .height(40.dp),
shape = RoundedCornerShape(8.dp) shape = RoundedCornerShape(8.dp)
) { ) {
Text("+", style = MaterialTheme.typography.labelMedium) Text("+", style = MaterialTheme.typography.labelMedium)
} }
} }
// Ping button
TextButton(onClick = { viewModel.pingAllServers() }) {
Text("Ping All", style = MaterialTheme.typography.labelSmall)
}
// IPv4/IPv6 preference // IPv4/IPv6 preference
Spacer(modifier = Modifier.height(8.dp)) Spacer(modifier = Modifier.height(8.dp))
Row( Row(
@@ -189,6 +237,36 @@ fun InCallScreen(
modifier = Modifier.fillMaxWidth(0.6f) modifier = Modifier.fillMaxWidth(0.6f)
) )
// Recent rooms
val recentRooms by viewModel.recentRooms.collectAsState()
if (recentRooms.isNotEmpty()) {
Spacer(modifier = Modifier.height(8.dp))
FlowRow(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.Center
) {
recentRooms.forEach { recent ->
Surface(
onClick = {
viewModel.setRoomName(recent.room)
// Select matching server
val idx = servers.indexOfFirst { it.address == recent.relay }
if (idx >= 0) viewModel.selectServer(idx)
},
shape = RoundedCornerShape(16.dp),
color = MaterialTheme.colorScheme.surfaceVariant,
modifier = Modifier.padding(2.dp)
) {
Text(
text = recent.room,
style = MaterialTheme.typography.labelSmall,
modifier = Modifier.padding(horizontal = 12.dp, vertical = 4.dp)
)
}
}
}
}
Spacer(modifier = Modifier.height(24.dp)) Spacer(modifier = Modifier.height(24.dp))
Button( Button(
@@ -218,6 +296,17 @@ fun InCallScreen(
color = MaterialTheme.colorScheme.error color = MaterialTheme.colorScheme.error
) )
} }
// Debug report card — shown after call ends
if (debugReportAvailable || debugReportStatus != null) {
Spacer(modifier = Modifier.height(24.dp))
DebugReportCard(
available = debugReportAvailable,
status = debugReportStatus,
onSend = { viewModel.sendDebugReport() },
onDismiss = { viewModel.dismissDebugReport() }
)
}
} else { } else {
// In-call UI // In-call UI
Spacer(modifier = Modifier.height(16.dp)) Spacer(modifier = Modifier.height(16.dp))
@@ -229,18 +318,44 @@ fun InCallScreen(
QualityIndicator(qualityTier, stats.qualityLabel) QualityIndicator(qualityTier, stats.qualityLabel)
if (stats.roomParticipantCount > 0) { if (stats.roomParticipantCount > 0) {
// Dedup by fingerprint — same key = same person, even if
// relay hasn't cleaned up stale entries yet.
val unique = stats.roomParticipants
.distinctBy { it.fingerprint.ifEmpty { it.displayName } }
Spacer(modifier = Modifier.height(8.dp)) Spacer(modifier = Modifier.height(8.dp))
Text( Text(
text = "${stats.roomParticipantCount} in room", text = "${unique.size} in room",
style = MaterialTheme.typography.bodySmall, style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant color = MaterialTheme.colorScheme.onSurfaceVariant
) )
stats.roomParticipants.forEach { member -> unique.forEach { member ->
Text( Row(
text = member.displayName, verticalAlignment = Alignment.CenterVertically,
style = MaterialTheme.typography.labelSmall, modifier = Modifier.padding(vertical = 2.dp)
color = MaterialTheme.colorScheme.onSurfaceVariant ) {
) com.wzp.ui.components.Identicon(
fingerprint = member.fingerprint.ifEmpty { member.displayName },
size = 28.dp,
)
Spacer(modifier = Modifier.width(8.dp))
Column {
Text(
text = member.displayName,
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
if (member.fingerprint.isNotEmpty()) {
com.wzp.ui.components.CopyableFingerprint(
fingerprint = member.fingerprint.take(16),
style = MaterialTheme.typography.labelSmall.copy(
fontSize = 9.sp,
fontFamily = FontFamily.Monospace,
),
color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.6f),
)
}
}
}
} }
} }
@@ -428,15 +543,20 @@ private fun AudioLevelBar(audioLevel: Int) {
color = MaterialTheme.colorScheme.onSurfaceVariant color = MaterialTheme.colorScheme.onSurfaceVariant
) )
Spacer(modifier = Modifier.height(4.dp)) Spacer(modifier = Modifier.height(4.dp))
LinearProgressIndicator( Box(
progress = level,
modifier = Modifier modifier = Modifier
.fillMaxWidth(0.6f) .fillMaxWidth(0.6f)
.height(6.dp) .height(6.dp)
.clip(RoundedCornerShape(3.dp)), .clip(RoundedCornerShape(3.dp))
color = MaterialTheme.colorScheme.primary, .background(MaterialTheme.colorScheme.surfaceVariant)
trackColor = MaterialTheme.colorScheme.surfaceVariant, ) {
) Box(
modifier = Modifier
.fillMaxWidth(level)
.height(6.dp)
.background(MaterialTheme.colorScheme.primary)
)
}
} }
} }
@@ -588,3 +708,70 @@ private fun StatItem(label: String, value: String) {
) )
} }
} }
@Composable
private fun DebugReportCard(
available: Boolean,
status: String?,
onSend: () -> Unit,
onDismiss: () -> Unit
) {
Surface(
modifier = Modifier.fillMaxWidth(),
color = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.7f),
shape = RoundedCornerShape(12.dp)
) {
Column(
modifier = Modifier.padding(16.dp),
horizontalAlignment = Alignment.CenterHorizontally
) {
Text(
text = "Debug Report",
style = MaterialTheme.typography.titleSmall.copy(fontWeight = FontWeight.Bold),
color = MaterialTheme.colorScheme.onSurface
)
Spacer(modifier = Modifier.height(4.dp))
Text(
text = "Email call recordings, logs & stats for analysis",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
textAlign = TextAlign.Center
)
Spacer(modifier = Modifier.height(12.dp))
when {
status != null && status.startsWith("Error") -> {
Text(
text = status,
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.error
)
Spacer(modifier = Modifier.height(8.dp))
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
OutlinedButton(onClick = onSend) { Text("Retry") }
TextButton(onClick = onDismiss) { Text("Dismiss") }
}
}
status != null && status != "ready" -> {
// Preparing zip...
Text(
text = status,
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
}
available -> {
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
Button(onClick = onSend) {
Text("Email Report")
}
TextButton(onClick = onDismiss) {
Text("Skip")
}
}
}
}
}
}
}

View File

@@ -0,0 +1,141 @@
package com.wzp.ui.components
import android.widget.Toast
import androidx.compose.foundation.Canvas
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.geometry.Size
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.LocalClipboardManager
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.text.AnnotatedString
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
import kotlin.math.min
/**
* Deterministic identicon — generates a unique 5x5 symmetric pattern
* from a hex fingerprint string. Identical algorithm to the desktop
* TypeScript implementation in identicon.ts.
*/
@Composable
fun Identicon(
fingerprint: String,
size: Dp = 36.dp,
clickToCopy: Boolean = true,
modifier: Modifier = Modifier,
) {
val clipboard = LocalClipboardManager.current
val context = LocalContext.current
val bytes = hashBytes(fingerprint)
val (bg, fg) = deriveColors(bytes)
val grid = buildGrid(bytes)
Canvas(
modifier = modifier
.size(size)
.clip(RoundedCornerShape(size * 0.12f))
.then(
if (clickToCopy && fingerprint.isNotEmpty()) {
Modifier.clickable {
clipboard.setText(AnnotatedString(fingerprint))
Toast.makeText(context, "Copied", Toast.LENGTH_SHORT).show()
}
} else Modifier
)
) {
val cellW = this.size.width / 5f
val cellH = this.size.height / 5f
// Background
drawRect(color = bg, size = this.size)
// Foreground cells
for (y in 0 until 5) {
for (x in 0 until 5) {
if (grid[y][x]) {
drawRect(
color = fg,
topLeft = Offset(x * cellW, y * cellH),
size = Size(cellW, cellH),
)
}
}
}
}
}
/**
* Fingerprint text that copies to clipboard on tap.
*/
@Composable
fun CopyableFingerprint(
fingerprint: String,
modifier: Modifier = Modifier,
style: androidx.compose.ui.text.TextStyle = androidx.compose.material3.MaterialTheme.typography.bodySmall,
color: Color = Color.Unspecified,
) {
val clipboard = LocalClipboardManager.current
val context = LocalContext.current
androidx.compose.material3.Text(
text = fingerprint,
style = style,
color = color,
modifier = modifier.clickable {
if (fingerprint.isNotEmpty()) {
clipboard.setText(AnnotatedString(fingerprint))
Toast.makeText(context, "Fingerprint copied", Toast.LENGTH_SHORT).show()
}
}
)
}
// --- Internal helpers (matching desktop identicon.ts) ---
private fun hashBytes(hex: String): List<Int> {
val clean = hex.filter { it.isLetterOrDigit() }
val bytes = mutableListOf<Int>()
var i = 0
while (i + 1 < clean.length) {
val b = clean.substring(i, i + 2).toIntOrNull(16) ?: 0
bytes.add(b)
i += 2
}
// Pad to at least 16 bytes
while (bytes.size < 16) bytes.add(0)
return bytes
}
private fun deriveColors(bytes: List<Int>): Pair<Color, Color> {
val hue1 = bytes[0] * 360f / 256f
val hue2 = (bytes[1] * 360f / 256f + 120f) % 360f
val bg = hslToColor(hue1, 0.65f, 0.35f)
val fg = hslToColor(hue2, 0.70f, 0.55f)
return bg to fg
}
private fun buildGrid(bytes: List<Int>): List<List<Boolean>> {
return (0 until 5).map { y ->
val left = (0 until 3).map { x ->
val idx = 2 + y * 3 + x
bytes[idx % bytes.size] > 128
}
// Mirror: col3 = col1, col4 = col0
listOf(left[0], left[1], left[2], left[1], left[0])
}
}
private fun hslToColor(h: Float, s: Float, l: Float): Color {
val k = { n: Float -> (n + h / 30f) % 12f }
val a = s * min(l, 1f - l)
val f = { n: Float ->
l - a * maxOf(-1f, minOf(k(n) - 3f, minOf(9f - k(n), 1f)))
}
return Color(f(0f), f(8f), f(4f))
}

View File

@@ -0,0 +1,520 @@
package com.wzp.ui.settings
import android.content.ClipData
import android.content.ClipboardManager
import android.content.Context
import android.widget.Toast
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.ExperimentalLayoutApi
import androidx.compose.foundation.layout.FlowRow
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.Button
import androidx.compose.material3.ButtonDefaults
import androidx.compose.material3.Divider
import androidx.compose.material3.FilledTonalButton
import androidx.compose.material3.FilledTonalIconButton
import androidx.compose.material3.IconButtonDefaults
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedButton
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Slider
import androidx.compose.material3.Surface
import androidx.compose.material3.Switch
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableFloatStateOf
import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.runtime.toMutableStateList
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import com.wzp.ui.call.CallViewModel
import com.wzp.ui.call.ServerEntry
@OptIn(ExperimentalLayoutApi::class)
@Composable
fun SettingsScreen(
viewModel: CallViewModel,
onBack: () -> Unit
) {
val context = LocalContext.current
// Snapshot current values into local draft state
val currentAlias by viewModel.alias.collectAsState()
val currentSeedHex by viewModel.seedHex.collectAsState()
val currentServers by viewModel.servers.collectAsState()
val currentSelectedServer by viewModel.selectedServer.collectAsState()
val currentRoomName by viewModel.roomName.collectAsState()
val currentPreferIPv6 by viewModel.preferIPv6.collectAsState()
val currentPlayoutGain by viewModel.playoutGainDb.collectAsState()
val currentCaptureGain by viewModel.captureGainDb.collectAsState()
val currentAecEnabled by viewModel.aecEnabled.collectAsState()
// Draft state — initialized from current values
var draftAlias by remember { mutableStateOf(currentAlias) }
var draftSeedHex by remember { mutableStateOf(currentSeedHex) }
val draftServers = remember { currentServers.toMutableStateList() }
var draftSelectedServer by remember { mutableIntStateOf(currentSelectedServer) }
var draftRoomName by remember { mutableStateOf(currentRoomName) }
var draftPreferIPv6 by remember { mutableStateOf(currentPreferIPv6) }
var draftPlayoutGain by remember { mutableFloatStateOf(currentPlayoutGain) }
var draftCaptureGain by remember { mutableFloatStateOf(currentCaptureGain) }
var draftAecEnabled by remember { mutableStateOf(currentAecEnabled) }
// Track if anything changed
val hasChanges = draftAlias != currentAlias ||
draftSeedHex != currentSeedHex ||
draftServers.toList() != currentServers ||
draftSelectedServer != currentSelectedServer ||
draftRoomName != currentRoomName ||
draftPreferIPv6 != currentPreferIPv6 ||
draftPlayoutGain != currentPlayoutGain ||
draftCaptureGain != currentCaptureGain ||
draftAecEnabled != currentAecEnabled
var showAddServerDialog by remember { mutableStateOf(false) }
var showRestoreKeyDialog by remember { mutableStateOf(false) }
Surface(
modifier = Modifier.fillMaxSize(),
color = MaterialTheme.colorScheme.background
) {
Column(
modifier = Modifier
.fillMaxSize()
.padding(24.dp)
.verticalScroll(rememberScrollState())
) {
// Header
Row(
modifier = Modifier.fillMaxWidth(),
verticalAlignment = Alignment.CenterVertically
) {
TextButton(onClick = onBack) {
Text("< Back")
}
Spacer(modifier = Modifier.weight(1f))
Text(
text = "Settings",
style = MaterialTheme.typography.headlineSmall.copy(
fontWeight = FontWeight.Bold
),
color = MaterialTheme.colorScheme.primary
)
Spacer(modifier = Modifier.weight(1f))
// Save button — only enabled when changes exist
Button(
onClick = {
viewModel.setAlias(draftAlias)
if (draftSeedHex != currentSeedHex) viewModel.restoreSeed(draftSeedHex)
viewModel.applyServers(draftServers.toList(), draftSelectedServer)
viewModel.setRoomName(draftRoomName)
viewModel.setPreferIPv6(draftPreferIPv6)
viewModel.setPlayoutGainDb(draftPlayoutGain)
viewModel.setCaptureGainDb(draftCaptureGain)
viewModel.setAecEnabled(draftAecEnabled)
Toast.makeText(context, "Settings saved", Toast.LENGTH_SHORT).show()
onBack()
},
enabled = hasChanges
) {
Text("Save")
}
}
Spacer(modifier = Modifier.height(24.dp))
// --- Identity ---
SectionHeader("Identity")
OutlinedTextField(
value = draftAlias,
onValueChange = { draftAlias = it },
label = { Text("Display Name") },
singleLine = true,
modifier = Modifier.fillMaxWidth()
)
Spacer(modifier = Modifier.height(16.dp))
// Fingerprint display with identicon
val fingerprint = if (draftSeedHex.length >= 16) draftSeedHex.take(16).uppercase() else "Not generated"
Text(
text = "Fingerprint",
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier.padding(vertical = 4.dp)
) {
com.wzp.ui.components.Identicon(
fingerprint = draftSeedHex,
size = 40.dp,
)
Spacer(modifier = Modifier.width(12.dp))
com.wzp.ui.components.CopyableFingerprint(
fingerprint = fingerprint.chunked(4).joinToString(" "),
style = MaterialTheme.typography.bodyMedium.copy(
fontFamily = FontFamily.Monospace
),
color = MaterialTheme.colorScheme.onSurface,
)
}
Spacer(modifier = Modifier.height(12.dp))
// Key backup/restore
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
FilledTonalButton(onClick = {
val clipboard = context.getSystemService(Context.CLIPBOARD_SERVICE) as ClipboardManager
clipboard.setPrimaryClip(ClipData.newPlainText("WZP Key", draftSeedHex))
Toast.makeText(context, "Key copied to clipboard", Toast.LENGTH_SHORT).show()
}) {
Text("Copy Key")
}
OutlinedButton(onClick = { showRestoreKeyDialog = true }) {
Text("Restore Key")
}
}
Spacer(modifier = Modifier.height(24.dp))
Divider()
Spacer(modifier = Modifier.height(16.dp))
// --- Audio ---
SectionHeader("Audio Defaults")
GainSlider(
label = "Voice Volume",
gainDb = draftPlayoutGain,
onGainChange = { draftPlayoutGain = Math.round(it).toFloat() }
)
Spacer(modifier = Modifier.height(4.dp))
GainSlider(
label = "Mic Gain",
gainDb = draftCaptureGain,
onGainChange = { draftCaptureGain = Math.round(it).toFloat() }
)
Spacer(modifier = Modifier.height(12.dp))
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier.fillMaxWidth()
) {
Column(modifier = Modifier.weight(1f)) {
Text(
text = "Echo Cancellation (AEC)",
style = MaterialTheme.typography.bodyMedium
)
Text(
text = "Disable if audio sounds distorted",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
}
Switch(
checked = draftAecEnabled,
onCheckedChange = { draftAecEnabled = it }
)
}
Spacer(modifier = Modifier.height(24.dp))
Divider()
Spacer(modifier = Modifier.height(16.dp))
// --- Servers ---
SectionHeader("Servers")
FlowRow(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.Start,
verticalArrangement = Arrangement.spacedBy(4.dp)
) {
draftServers.forEachIndexed { idx, entry ->
val isSelected = draftSelectedServer == idx
Row(verticalAlignment = Alignment.CenterVertically) {
FilledTonalIconButton(
onClick = { draftSelectedServer = idx },
modifier = Modifier
.padding(end = 2.dp)
.height(36.dp)
.width(140.dp),
shape = RoundedCornerShape(8.dp),
colors = if (isSelected) {
IconButtonDefaults.filledTonalIconButtonColors(
containerColor = MaterialTheme.colorScheme.primaryContainer,
contentColor = MaterialTheme.colorScheme.onPrimaryContainer
)
} else {
IconButtonDefaults.filledTonalIconButtonColors()
}
) {
Text(
text = entry.label,
style = MaterialTheme.typography.labelSmall,
maxLines = 1
)
}
// Show remove button for non-default servers
if (idx >= 2) {
TextButton(
onClick = {
draftServers.removeAt(idx)
if (draftSelectedServer >= draftServers.size) {
draftSelectedServer = 0
}
},
modifier = Modifier.height(36.dp)
) {
Text("X", color = MaterialTheme.colorScheme.error)
}
}
}
}
}
Spacer(modifier = Modifier.height(8.dp))
OutlinedButton(
onClick = { showAddServerDialog = true },
shape = RoundedCornerShape(8.dp)
) {
Text("+ Add Server")
}
// Show selected server address
Spacer(modifier = Modifier.height(8.dp))
Text(
text = "Default: ${draftServers.getOrNull(draftSelectedServer)?.address ?: "none"}",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
Spacer(modifier = Modifier.height(24.dp))
Divider()
Spacer(modifier = Modifier.height(16.dp))
// --- Network ---
SectionHeader("Network")
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier.fillMaxWidth()
) {
Text(
text = "Prefer IPv6",
style = MaterialTheme.typography.bodyMedium,
modifier = Modifier.weight(1f)
)
Switch(
checked = draftPreferIPv6,
onCheckedChange = { draftPreferIPv6 = it }
)
}
Spacer(modifier = Modifier.height(24.dp))
Divider()
Spacer(modifier = Modifier.height(16.dp))
// --- Room ---
SectionHeader("Room")
OutlinedTextField(
value = draftRoomName,
onValueChange = { draftRoomName = it },
label = { Text("Default Room") },
singleLine = true,
modifier = Modifier.fillMaxWidth()
)
Spacer(modifier = Modifier.height(32.dp))
}
}
if (showAddServerDialog) {
AddServerDialog(
onDismiss = { showAddServerDialog = false },
onAdd = { host, port, label ->
draftServers.add(ServerEntry("$host:$port", label))
showAddServerDialog = false
}
)
}
if (showRestoreKeyDialog) {
RestoreKeyDialog(
onDismiss = { showRestoreKeyDialog = false },
onRestore = { hex ->
draftSeedHex = hex
showRestoreKeyDialog = false
Toast.makeText(context, "Key staged — press Save to apply", Toast.LENGTH_SHORT).show()
}
)
}
}
@Composable
private fun SectionHeader(title: String) {
Text(
text = title,
style = MaterialTheme.typography.titleMedium.copy(fontWeight = FontWeight.Bold),
color = MaterialTheme.colorScheme.primary
)
Spacer(modifier = Modifier.height(8.dp))
}
@Composable
private fun GainSlider(label: String, gainDb: Float, onGainChange: (Float) -> Unit) {
Column(
modifier = Modifier.fillMaxWidth(),
horizontalAlignment = Alignment.CenterHorizontally
) {
val sign = if (gainDb >= 0) "+" else ""
Text(
text = "$label: ${sign}${"%.0f".format(gainDb)} dB",
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
Slider(
value = gainDb,
onValueChange = onGainChange,
valueRange = -20f..20f,
steps = 0,
modifier = Modifier.fillMaxWidth()
)
}
}
@Composable
private fun AddServerDialog(
onDismiss: () -> Unit,
onAdd: (host: String, port: String, label: String) -> Unit
) {
var host by remember { mutableStateOf("") }
var port by remember { mutableStateOf("4433") }
var label by remember { mutableStateOf("") }
AlertDialog(
onDismissRequest = onDismiss,
title = { Text("Add Server") },
text = {
Column {
OutlinedTextField(
value = host,
onValueChange = { host = it },
label = { Text("Host (IP or domain)") },
singleLine = true,
modifier = Modifier.fillMaxWidth()
)
Spacer(modifier = Modifier.height(8.dp))
OutlinedTextField(
value = port,
onValueChange = { port = it },
label = { Text("Port") },
singleLine = true,
modifier = Modifier.fillMaxWidth()
)
Spacer(modifier = Modifier.height(8.dp))
OutlinedTextField(
value = label,
onValueChange = { label = it },
label = { Text("Label (optional)") },
singleLine = true,
modifier = Modifier.fillMaxWidth()
)
}
},
confirmButton = {
TextButton(
onClick = {
if (host.isNotBlank()) {
val displayLabel = label.ifBlank { host }
onAdd(host.trim(), port.trim(), displayLabel)
}
}
) { Text("Add") }
},
dismissButton = {
TextButton(onClick = onDismiss) { Text("Cancel") }
}
)
}
@Composable
private fun RestoreKeyDialog(
onDismiss: () -> Unit,
onRestore: (hex: String) -> Unit
) {
var keyInput by remember { mutableStateOf("") }
var error by remember { mutableStateOf<String?>(null) }
AlertDialog(
onDismissRequest = onDismiss,
title = { Text("Restore Identity Key") },
text = {
Column {
Text(
text = "Paste your 64-character hex key below. This will replace your current identity.",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
Spacer(modifier = Modifier.height(8.dp))
OutlinedTextField(
value = keyInput,
onValueChange = {
keyInput = it.trim().lowercase()
error = null
},
label = { Text("Identity Key (hex)") },
singleLine = true,
modifier = Modifier.fillMaxWidth(),
isError = error != null
)
error?.let {
Text(
text = it,
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.error
)
}
}
},
confirmButton = {
TextButton(
onClick = {
val cleaned = keyInput.replace("\\s".toRegex(), "")
if (cleaned.length != 64 || !cleaned.all { it in '0'..'9' || it in 'a'..'f' }) {
error = "Key must be exactly 64 hex characters"
} else {
onRestore(cleaned)
}
}
) { Text("Restore") }
},
dismissButton = {
TextButton(onClick = onDismiss) { Text("Cancel") }
}
)
}

View File

@@ -0,0 +1,4 @@
<?xml version="1.0" encoding="utf-8"?>
<paths>
<cache-path name="debug" path="." />
</paths>

View File

@@ -17,7 +17,7 @@ wzp-crypto = { workspace = true }
wzp-transport = { workspace = true } wzp-transport = { workspace = true }
tokio = { workspace = true } tokio = { workspace = true }
tracing = { workspace = true } tracing = { workspace = true }
tracing-subscriber = { workspace = true } tracing-subscriber = { workspace = true, features = ["env-filter"] }
bytes = { workspace = true } bytes = { workspace = true }
serde = { workspace = true } serde = { workspace = true }
serde_json = "1" serde_json = "1"
@@ -28,6 +28,7 @@ libc = "0.2"
jni = { version = "0.21", default-features = false } jni = { version = "0.21", default-features = false }
rand = { workspace = true } rand = { workspace = true }
rustls = { version = "0.23", default-features = false, features = ["ring"] } rustls = { version = "0.23", default-features = false, features = ["ring"] }
tracing-android = "0.2"
[build-dependencies] [build-dependencies]
cc = "1" cc = "1"

View File

@@ -1,91 +1,128 @@
//! Lock-free SPSC ring buffers for audio PCM transfer between //! Lock-free SPSC ring buffer — "Reader-Detects-Lap" architecture.
//! Kotlin AudioRecord/AudioTrack threads and the Rust engine.
//! //!
//! These use a simple spin-free design: the producer writes and advances //! SPSC invariant: the producer ONLY writes `write_pos`, the consumer
//! a write cursor, the consumer reads and advances a read cursor. //! ONLY writes `read_pos`. Neither thread touches the other's cursor.
//! Both cursors are atomic so no mutex is needed. //!
//! On overflow (writer laps the reader), the writer simply overwrites
//! old buffer data. The reader detects the lap via `available() >
//! RING_CAPACITY` and snaps its own `read_pos` forward.
//!
//! Capacity is a power of 2 for bitmask indexing (no modulo).
use std::sync::atomic::{AtomicUsize, Ordering}; use std::sync::atomic::{AtomicU64, AtomicUsize, Ordering};
/// Ring buffer capacity in i16 samples. /// Ring buffer capacity — power of 2 for bitmask indexing.
/// 960 samples * 10 frames = ~200ms of audio at 48kHz mono. /// 16384 samples = 341.3ms at 48kHz mono. 70% more headroom
const RING_CAPACITY: usize = 960 * 10; /// than the previous 9600 (200ms) for surviving Android GC pauses.
const RING_CAPACITY: usize = 16384; // 2^14
const RING_MASK: usize = RING_CAPACITY - 1;
/// Lock-free single-producer single-consumer ring buffer for i16 PCM samples. /// Lock-free single-producer single-consumer ring buffer for i16 PCM samples.
pub struct AudioRing { pub struct AudioRing {
buf: Box<[i16; RING_CAPACITY]>, buf: Box<[i16]>,
/// Monotonically increasing write cursor. ONLY written by producer.
write_pos: AtomicUsize, write_pos: AtomicUsize,
/// Monotonically increasing read cursor. ONLY written by consumer.
read_pos: AtomicUsize, read_pos: AtomicUsize,
/// Incremented by reader when it detects it was lapped (overflow).
overflow_count: AtomicU64,
/// Incremented by reader when ring is empty (underrun).
underrun_count: AtomicU64,
} }
// SAFETY: AudioRing is designed for SPSC — one thread writes, one reads. // SAFETY: AudioRing is SPSC — one thread writes (producer), one reads (consumer).
// The atomics ensure visibility. The buffer itself is never accessed // The producer only writes write_pos. The consumer only writes read_pos.
// from the same index by both threads simultaneously because the // Neither thread writes the other's cursor. Buffer indices are derived from
// producer only writes to positions between write_pos and read_pos, // the owning thread's cursor, ensuring no concurrent access to the same index.
// and the consumer only reads from positions between read_pos and write_pos.
unsafe impl Send for AudioRing {} unsafe impl Send for AudioRing {}
unsafe impl Sync for AudioRing {} unsafe impl Sync for AudioRing {}
impl AudioRing { impl AudioRing {
pub fn new() -> Self { pub fn new() -> Self {
debug_assert!(RING_CAPACITY.is_power_of_two());
Self { Self {
buf: Box::new([0i16; RING_CAPACITY]), buf: vec![0i16; RING_CAPACITY].into_boxed_slice(),
write_pos: AtomicUsize::new(0), write_pos: AtomicUsize::new(0),
read_pos: AtomicUsize::new(0), read_pos: AtomicUsize::new(0),
overflow_count: AtomicU64::new(0),
underrun_count: AtomicU64::new(0),
} }
} }
/// Number of samples available to read. /// Number of samples available to read (clamped to capacity).
pub fn available(&self) -> usize { pub fn available(&self) -> usize {
let w = self.write_pos.load(Ordering::Acquire); let w = self.write_pos.load(Ordering::Acquire);
let r = self.read_pos.load(Ordering::Acquire); let r = self.read_pos.load(Ordering::Relaxed);
w.wrapping_sub(r) w.wrapping_sub(r).min(RING_CAPACITY)
} }
/// Number of samples that can be written without overwriting. /// Number of samples that can be written without overwriting unread data.
pub fn free_space(&self) -> usize { pub fn free_space(&self) -> usize {
RING_CAPACITY - self.available() RING_CAPACITY.saturating_sub(self.available())
} }
/// Write samples into the ring. Returns number of samples written. /// Write samples into the ring. Returns number of samples written.
/// Drops oldest samples if the ring is full. ///
/// If the ring is full, old data is silently overwritten. The reader
/// will detect the lap and self-correct. The writer NEVER touches
/// `read_pos` — this is the key invariant that prevents cursor desync.
pub fn write(&self, samples: &[i16]) -> usize { pub fn write(&self, samples: &[i16]) -> usize {
let w = self.write_pos.load(Ordering::Relaxed);
let count = samples.len().min(RING_CAPACITY); let count = samples.len().min(RING_CAPACITY);
let w = self.write_pos.load(Ordering::Relaxed);
for i in 0..count { for i in 0..count {
let idx = (w + i) % RING_CAPACITY;
// SAFETY: We're the only writer, and the reader won't read
// past read_pos which we haven't advanced past yet.
unsafe { unsafe {
let ptr = self.buf.as_ptr() as *mut i16; let ptr = self.buf.as_ptr() as *mut i16;
*ptr.add(idx) = samples[i]; *ptr.add((w + i) & RING_MASK) = samples[i];
} }
} }
self.write_pos.store(w.wrapping_add(count), Ordering::Release); self.write_pos.store(w.wrapping_add(count), Ordering::Release);
// If we overwrote unread data, advance read_pos
if self.available() > RING_CAPACITY {
let new_read = self.write_pos.load(Ordering::Relaxed).wrapping_sub(RING_CAPACITY);
self.read_pos.store(new_read, Ordering::Release);
}
count count
} }
/// Read samples from the ring into `out`. Returns number of samples read. /// Read samples from the ring into `out`. Returns number of samples read.
///
/// If the writer has lapped the reader (overflow), `read_pos` is snapped
/// forward to the oldest valid data. This is safe because only the
/// reader thread writes `read_pos`.
pub fn read(&self, out: &mut [i16]) -> usize { pub fn read(&self, out: &mut [i16]) -> usize {
let avail = self.available(); let w = self.write_pos.load(Ordering::Acquire);
let count = out.len().min(avail); let mut r = self.read_pos.load(Ordering::Relaxed);
let mut avail = w.wrapping_sub(r);
// Lap detection: writer has overwritten our unread data.
// Snap read_pos forward to oldest valid data in the buffer.
if avail > RING_CAPACITY {
r = w.wrapping_sub(RING_CAPACITY);
avail = RING_CAPACITY;
self.overflow_count.fetch_add(1, Ordering::Relaxed);
}
let count = out.len().min(avail);
if count == 0 {
if w == r {
self.underrun_count.fetch_add(1, Ordering::Relaxed);
}
return 0;
}
let r = self.read_pos.load(Ordering::Relaxed);
for i in 0..count { for i in 0..count {
let idx = (r + i) % RING_CAPACITY; out[i] = unsafe { *self.buf.as_ptr().add((r + i) & RING_MASK) };
out[i] = unsafe { *self.buf.as_ptr().add(idx) };
} }
self.read_pos.store(r.wrapping_add(count), Ordering::Release); self.read_pos.store(r.wrapping_add(count), Ordering::Release);
count count
} }
/// Number of overflow events (reader was lapped by writer).
pub fn overflow_count(&self) -> u64 {
self.overflow_count.load(Ordering::Relaxed)
}
/// Number of underrun events (reader found empty buffer).
pub fn underrun_count(&self) -> u64 {
self.underrun_count.load(Ordering::Relaxed)
}
} }

View File

@@ -39,6 +39,7 @@ pub struct CallStartConfig {
pub room: String, pub room: String,
pub auth_token: Vec<u8>, pub auth_token: Vec<u8>,
pub identity_seed: [u8; 32], pub identity_seed: [u8; 32],
pub alias: Option<String>,
} }
impl Default for CallStartConfig { impl Default for CallStartConfig {
@@ -49,6 +50,7 @@ impl Default for CallStartConfig {
room: String::new(), room: String::new(),
auth_token: Vec::new(), auth_token: Vec::new(),
identity_seed: [0u8; 32], identity_seed: [0u8; 32],
alias: None,
} }
} }
} }
@@ -65,6 +67,9 @@ pub(crate) struct EngineState {
pub playout_ring: AudioRing, pub playout_ring: AudioRing,
/// Current audio level (RMS) for UI display, updated by capture path. /// Current audio level (RMS) for UI display, updated by capture path.
pub audio_level_rms: AtomicU32, pub audio_level_rms: AtomicU32,
/// QUIC transport handle — stored so stop_call() can close it immediately,
/// triggering relay-side leave + RoomUpdate broadcast.
pub quic_transport: Mutex<Option<Arc<wzp_transport::QuinnTransport>>>,
} }
pub struct WzpEngine { pub struct WzpEngine {
@@ -85,6 +90,7 @@ impl WzpEngine {
capture_ring: AudioRing::new(), capture_ring: AudioRing::new(),
playout_ring: AudioRing::new(), playout_ring: AudioRing::new(),
audio_level_rms: AtomicU32::new(0), audio_level_rms: AtomicU32::new(0),
quic_transport: Mutex::new(None),
}); });
Self { Self {
state, state,
@@ -117,6 +123,7 @@ impl WzpEngine {
let room = config.room.clone(); let room = config.room.clone();
let identity_seed = config.identity_seed; let identity_seed = config.identity_seed;
let profile = config.profile; let profile = config.profile;
let alias = config.alias.clone();
let state = self.state.clone(); let state = self.state.clone();
self.state.running.store(true, Ordering::Release); self.state.running.store(true, Ordering::Release);
@@ -124,7 +131,7 @@ impl WzpEngine {
let state_clone = state.clone(); let state_clone = state.clone();
runtime.block_on(async move { runtime.block_on(async move {
if let Err(e) = run_call(relay_addr, &room, &identity_seed, profile, state_clone).await if let Err(e) = run_call(relay_addr, &room, &identity_seed, profile, alias.as_deref(), state_clone).await
{ {
error!("call failed: {e}"); error!("call failed: {e}");
} }
@@ -141,12 +148,25 @@ impl WzpEngine {
} }
pub fn stop_call(&mut self) { pub fn stop_call(&mut self) {
info!("stop_call: setting running=false");
self.state.running.store(false, Ordering::Release); self.state.running.store(false, Ordering::Release);
// Close QUIC connection — this wakes up all blocked recv/send futures
// inside block_on(run_call(...)) on the JNI thread. run_call will then
// wait up to 500ms for the peer to acknowledge the close before returning.
if let Some(transport) = self.state.quic_transport.lock().unwrap().take() {
info!("stop_call: closing QUIC connection");
transport.close_now();
}
let _ = self.state.command_tx.send(EngineCommand::Stop); let _ = self.state.command_tx.send(EngineCommand::Stop);
// Note: the runtime is still blocked in block_on(run_call(...)) on the
// start_call thread. Once run_call exits (triggered by running=false +
// connection close above), block_on returns and stores the runtime in
// self.tokio_runtime. We don't need to shut it down here.
if let Some(rt) = self.tokio_runtime.take() { if let Some(rt) = self.tokio_runtime.take() {
rt.shutdown_background(); rt.shutdown_timeout(std::time::Duration::from_millis(100));
} }
self.call_start = None; self.call_start = None;
info!("stop_call: done");
} }
pub fn set_mute(&self, muted: bool) { pub fn set_mute(&self, muted: bool) {
@@ -163,6 +183,9 @@ impl WzpEngine {
stats.duration_secs = start.elapsed().as_secs_f64(); stats.duration_secs = start.elapsed().as_secs_f64();
} }
stats.audio_level = self.state.audio_level_rms.load(Ordering::Relaxed); stats.audio_level = self.state.audio_level_rms.load(Ordering::Relaxed);
stats.playout_overflows = self.state.playout_ring.overflow_count();
stats.playout_underruns = self.state.playout_ring.underrun_count();
stats.capture_overflows = self.state.capture_ring.overflow_count();
stats stats
} }
@@ -204,6 +227,7 @@ async fn run_call(
room: &str, room: &str,
identity_seed: &[u8; 32], identity_seed: &[u8; 32],
profile: QualityProfile, profile: QualityProfile,
alias: Option<&str>,
state: Arc<EngineState>, state: Arc<EngineState>,
) -> Result<(), anyhow::Error> { ) -> Result<(), anyhow::Error> {
let _ = rustls::crypto::ring::default_provider().install_default(); let _ = rustls::crypto::ring::default_provider().install_default();
@@ -219,6 +243,9 @@ async fn run_call(
let transport = Arc::new(wzp_transport::QuinnTransport::new(conn)); let transport = Arc::new(wzp_transport::QuinnTransport::new(conn));
// Store transport handle so stop_call() can close the connection immediately
*state.quic_transport.lock().unwrap() = Some(transport.clone());
// Crypto handshake // Crypto handshake
let mut kx = WarzoneKeyExchange::from_identity_seed(identity_seed); let mut kx = WarzoneKeyExchange::from_identity_seed(identity_seed);
let ephemeral_pub = kx.generate_ephemeral(); let ephemeral_pub = kx.generate_ephemeral();
@@ -238,6 +265,7 @@ async fn run_call(
QualityProfile::DEGRADED, QualityProfile::DEGRADED,
QualityProfile::CATASTROPHIC, QualityProfile::CATASTROPHIC,
], ],
alias: alias.map(|s| s.to_string()),
}; };
transport.send_signal(&offer).await?; transport.send_signal(&offer).await?;
info!("CallOffer sent, waiting for CallAnswer..."); info!("CallOffer sent, waiting for CallAnswer...");
@@ -296,8 +324,24 @@ async fn run_call(
let mut block_id: u8 = 0; let mut block_id: u8 = 0;
// Send task: capture ring → Opus encode → FEC → MediaPackets // Send task: capture ring → Opus encode → FEC → MediaPackets
//
// IMPORTANT: send_media() uses quinn's send_datagram() which is
// synchronous and returns Err(Blocked) when the congestion window
// is full. We MUST NOT break on send errors — that would kill the
// entire call. Instead we drop the packet and keep going.
let send_task = async { let send_task = async {
info!("send task started (Opus + RaptorQ FEC)"); info!("send task started (Opus + RaptorQ FEC)");
let mut send_errors: u64 = 0;
let mut last_send_error_log = Instant::now();
let mut last_stats_log = Instant::now();
let mut frames_sent: u64 = 0;
let mut frames_dropped: u64 = 0;
// Per-step timing accumulators (reset every stats log)
let mut t_agc_us: u64 = 0;
let mut t_opus_us: u64 = 0;
let mut t_fec_us: u64 = 0;
let mut t_send_us: u64 = 0;
let mut t_frames: u64 = 0;
loop { loop {
if !state.running.load(Ordering::Relaxed) { if !state.running.load(Ordering::Relaxed) {
break; break;
@@ -314,10 +358,19 @@ async fn run_call(
continue; continue;
} }
// Mute: zero out the buffer so Opus encodes silence.
// We still read from the ring to prevent it from filling up.
if state.muted.load(Ordering::Relaxed) {
capture_buf.fill(0);
}
// AGC: normalize capture volume before encoding // AGC: normalize capture volume before encoding
let t0 = Instant::now();
capture_agc.process_frame(&mut capture_buf); capture_agc.process_frame(&mut capture_buf);
t_agc_us += t0.elapsed().as_micros() as u64;
// Opus encode // Opus encode
let t0 = Instant::now();
let encoded_len = match encoder.encode(&capture_buf, &mut encode_buf) { let encoded_len = match encoder.encode(&capture_buf, &mut encode_buf) {
Ok(n) => n, Ok(n) => n,
Err(e) => { Err(e) => {
@@ -325,6 +378,7 @@ async fn run_call(
continue; continue;
} }
}; };
t_opus_us += t0.elapsed().as_micros() as u64;
let encoded = &encode_buf[..encoded_len]; let encoded = &encode_buf[..encoded_len];
// Build source packet // Build source packet
@@ -349,13 +403,30 @@ async fn run_call(
quality_report: None, quality_report: None,
}; };
// Send source packet // Send source packet — drop on error, never break
let t0 = Instant::now();
if let Err(e) = transport.send_media(&source_pkt).await { if let Err(e) = transport.send_media(&source_pkt).await {
error!("send error: {e}"); send_errors += 1;
break; frames_dropped += 1;
// Log first few errors, then throttle to once per second
if send_errors <= 3 || last_send_error_log.elapsed().as_secs() >= 1 {
warn!(
seq = s,
send_errors,
frames_dropped,
"send_media error (dropping packet): {e}"
);
last_send_error_log = Instant::now();
}
// Don't feed to FEC either — the source is lost
t_send_us += t0.elapsed().as_micros() as u64;
continue;
} }
t_send_us += t0.elapsed().as_micros() as u64;
frames_sent += 1;
// Feed encoded frame to FEC encoder // Feed encoded frame to FEC encoder
let t0 = Instant::now();
if let Err(e) = fec_enc.add_source_symbol(encoded) { if let Err(e) = fec_enc.add_source_symbol(encoded) {
warn!("fec add_source error: {e}"); warn!("fec add_source error: {e}");
} }
@@ -387,9 +458,11 @@ async fn run_call(
payload: Bytes::from(repair_data), payload: Bytes::from(repair_data),
quality_report: None, quality_report: None,
}; };
if let Err(e) = transport.send_media(&repair_pkt).await { // Drop repair packets on error — never break
error!("send repair error: {e}"); if let Err(_e) = transport.send_media(&repair_pkt).await {
break; send_errors += 1;
frames_dropped += 1;
// Don't log every repair failure — source error log covers it
} }
} }
if repair_count > 0 && (block_id % 50 == 0 || block_id == 0) { if repair_count > 0 && (block_id % 50 == 0 || block_id == 0) {
@@ -410,11 +483,32 @@ async fn run_call(
block_id = block_id.wrapping_add(1); block_id = block_id.wrapping_add(1);
frame_in_block = 0; frame_in_block = 0;
} }
t_fec_us += t0.elapsed().as_micros() as u64;
t_frames += 1;
if s % 500 == 0 { // Periodic stats every 5 seconds
info!(seq = s, block_id, frame_in_block, "sending"); if last_stats_log.elapsed().as_secs() >= 5 {
let avg = |total: u64| if t_frames > 0 { total / t_frames } else { 0 };
info!(
seq = s,
block_id,
frames_sent,
frames_dropped,
send_errors,
ring_avail = state.capture_ring.available(),
capture_overflows = state.capture_ring.overflow_count(),
avg_agc_us = avg(t_agc_us),
avg_opus_us = avg(t_opus_us),
avg_fec_us = avg(t_fec_us),
avg_send_us = avg(t_send_us),
avg_total_us = avg(t_agc_us + t_opus_us + t_fec_us + t_send_us),
"send stats"
);
t_agc_us = 0; t_opus_us = 0; t_fec_us = 0; t_send_us = 0; t_frames = 0;
last_stats_log = Instant::now();
} }
} }
info!(frames_sent, frames_dropped, send_errors, "send task ended");
}; };
// Pre-allocate decode buffer // Pre-allocate decode buffer
@@ -424,6 +518,10 @@ async fn run_call(
let recv_task = async { let recv_task = async {
let mut frames_decoded: u64 = 0; let mut frames_decoded: u64 = 0;
let mut fec_recovered: u64 = 0; let mut fec_recovered: u64 = 0;
let mut recv_errors: u64 = 0;
let mut last_recv_instant = Instant::now();
let mut max_recv_gap_ms: u64 = 0;
let mut last_stats_log = Instant::now();
info!("recv task started (Opus + RaptorQ FEC)"); info!("recv task started (Opus + RaptorQ FEC)");
loop { loop {
if !state.running.load(Ordering::Relaxed) { if !state.running.load(Ordering::Relaxed) {
@@ -431,6 +529,21 @@ async fn run_call(
} }
match transport_recv.recv_media().await { match transport_recv.recv_media().await {
Ok(Some(pkt)) => { Ok(Some(pkt)) => {
// Track recv gaps — large gaps indicate network or relay issues
let recv_gap_ms = last_recv_instant.elapsed().as_millis() as u64;
last_recv_instant = Instant::now();
if recv_gap_ms > max_recv_gap_ms {
max_recv_gap_ms = recv_gap_ms;
}
if recv_gap_ms > 500 {
warn!(
recv_gap_ms,
seq = pkt.header.seq,
is_repair = pkt.header.is_repair,
"large recv gap — possible network stall"
);
}
let is_repair = pkt.header.is_repair; let is_repair = pkt.header.is_repair;
let pkt_block = pkt.header.fec_block; let pkt_block = pkt.header.fec_block;
let pkt_symbol = pkt.header.fec_symbol; let pkt_symbol = pkt.header.fec_symbol;
@@ -447,7 +560,6 @@ async fn run_call(
if !is_repair { if !is_repair {
match decoder.decode(&pkt.payload, &mut decode_buf) { match decoder.decode(&pkt.payload, &mut decode_buf) {
Ok(samples) => { Ok(samples) => {
// AGC on playout — normalizes received audio volume
playout_agc.process_frame(&mut decode_buf[..samples]); playout_agc.process_frame(&mut decode_buf[..samples]);
state.playout_ring.write(&decode_buf[..samples]); state.playout_ring.write(&decode_buf[..samples]);
frames_decoded += 1; frames_decoded += 1;
@@ -462,13 +574,8 @@ async fn run_call(
} }
} }
// Try FEC recovery for this block // Try FEC recovery
// (useful when source packets were lost but repair arrived)
if let Ok(Some(recovered_frames)) = fec_dec.try_decode(pkt_block) { if let Ok(Some(recovered_frames)) = fec_dec.try_decode(pkt_block) {
// FEC recovered the block — any previously missing frames
// are now available. In a full jitter buffer implementation,
// we'd insert recovered frames at the right position.
// For now, log recovery for telemetry.
fec_recovered += recovered_frames.len() as u64; fec_recovered += recovered_frames.len() as u64;
if fec_recovered % 50 == 1 { if fec_recovered % 50 == 1 {
info!( info!(
@@ -485,24 +592,47 @@ async fn run_call(
fec_dec.expire_before(pkt_block.wrapping_sub(3)); fec_dec.expire_before(pkt_block.wrapping_sub(3));
} }
if frames_decoded == 1 || frames_decoded % 500 == 0 {
info!(frames_decoded, fec_recovered, "recv stats");
}
let mut stats = state.stats.lock().unwrap(); let mut stats = state.stats.lock().unwrap();
stats.frames_decoded = frames_decoded; stats.frames_decoded = frames_decoded;
stats.fec_recovered = fec_recovered; stats.fec_recovered = fec_recovered;
drop(stats);
// Periodic stats every 5 seconds
if last_stats_log.elapsed().as_secs() >= 5 {
info!(
frames_decoded,
fec_recovered,
recv_errors,
max_recv_gap_ms,
playout_avail = state.playout_ring.available(),
playout_overflows = state.playout_ring.overflow_count(),
playout_underruns = state.playout_ring.underrun_count(),
"recv stats"
);
max_recv_gap_ms = 0;
last_stats_log = Instant::now();
}
} }
Ok(None) => { Ok(None) => {
info!("relay disconnected"); info!(frames_decoded, fec_recovered, "relay disconnected (stream ended)");
break; break;
} }
Err(e) => { Err(e) => {
error!("recv error: {e}"); recv_errors += 1;
break; // Transient errors: log and keep going
let msg = e.to_string();
if msg.contains("closed") || msg.contains("reset") {
error!(recv_errors, "recv fatal: {e}");
break;
}
// Non-fatal: log throttled
if recv_errors <= 3 || recv_errors % 50 == 0 {
warn!(recv_errors, "recv error (continuing): {e}");
}
} }
} }
} }
info!(frames_decoded, fec_recovered, recv_errors, "recv task ended");
}; };
// Stats task — polls path quality + quinn RTT every 500ms // Stats task — polls path quality + quinn RTT every 500ms
@@ -564,12 +694,22 @@ async fn run_call(
}; };
tokio::select! { tokio::select! {
_ = send_task => {} _ = send_task => info!("send task ended"),
_ = recv_task => {} _ = recv_task => info!("recv task ended"),
_ = stats_task => {} _ = stats_task => info!("stats task ended"),
_ = signal_task => {} _ = signal_task => info!("signal task ended"),
} }
transport.close().await.ok(); // Send CONNECTION_CLOSE and wait up to 500ms for the peer to acknowledge.
// This ensures the relay sees the close even if the first packet is lost.
info!("closing QUIC connection...");
transport.close_now();
match tokio::time::timeout(
std::time::Duration::from_millis(500),
transport.connection().closed(),
).await {
Ok(_) => info!("QUIC connection closed cleanly"),
Err(_) => info!("QUIC close timed out (relay may not have ack'd)"),
}
Ok(()) Ok(())
} }

View File

@@ -1,6 +1,7 @@
//! JNI bridge for Android — thin layer between Kotlin and the WzpEngine. //! JNI bridge for Android — thin layer between Kotlin and the WzpEngine.
use std::panic; use std::panic;
use std::sync::Once;
use jni::objects::{JClass, JObject, JString}; use jni::objects::{JClass, JObject, JString};
use jni::sys::{jboolean, jint, jlong, jstring}; use jni::sys::{jboolean, jint, jlong, jstring};
@@ -28,12 +29,41 @@ fn profile_from_int(value: jint) -> QualityProfile {
} }
} }
static INIT_LOGGING: Once = Once::new();
/// Initialize tracing → Android logcat (tag "wzp_android").
/// Safe to call multiple times — only the first call takes effect.
fn init_logging() {
INIT_LOGGING.call_once(|| {
// Wrap in catch_unwind — sharded_slab allocation inside
// tracing_subscriber::registry() can crash on some Android
// devices if scudo malloc fails during early initialization.
let _ = std::panic::catch_unwind(|| {
use tracing_subscriber::layer::SubscriberExt;
use tracing_subscriber::util::SubscriberInitExt;
use tracing_subscriber::EnvFilter;
if let Ok(layer) = tracing_android::layer("wzp_android") {
// Filter: INFO for our crates, WARN for everything else.
// The jni crate emits VERBOSE logs for every method lookup
// (~10 lines per JNI call, 100+ calls/sec) which floods logcat
// and causes the system to kill the app.
let filter = EnvFilter::new("warn,wzp_android=info,wzp_proto=info,wzp_transport=info,wzp_codec=info,wzp_fec=info,wzp_crypto=info");
let _ = tracing_subscriber::registry()
.with(layer)
.with(filter)
.try_init();
}
});
});
}
#[unsafe(no_mangle)] #[unsafe(no_mangle)]
pub unsafe extern "system" fn Java_com_wzp_engine_WzpEngine_nativeInit( pub unsafe extern "system" fn Java_com_wzp_engine_WzpEngine_nativeInit(
_env: JNIEnv, _env: JNIEnv,
_class: JClass, _class: JClass,
) -> jlong { ) -> jlong {
let result = panic::catch_unwind(|| { let result = panic::catch_unwind(|| {
init_logging();
let handle = Box::new(EngineHandle { let handle = Box::new(EngineHandle {
engine: WzpEngine::new(), engine: WzpEngine::new(),
}); });
@@ -54,12 +84,14 @@ pub unsafe extern "system" fn Java_com_wzp_engine_WzpEngine_nativeStartCall(
room_j: JString, room_j: JString,
seed_hex_j: JString, seed_hex_j: JString,
token_j: JString, token_j: JString,
alias_j: JString,
) -> jint { ) -> jint {
let result = panic::catch_unwind(panic::AssertUnwindSafe(|| { let result = panic::catch_unwind(panic::AssertUnwindSafe(|| {
let relay_addr: String = env.get_string(&relay_addr_j).map(|s| s.into()).unwrap_or_default(); let relay_addr: String = env.get_string(&relay_addr_j).map(|s| s.into()).unwrap_or_default();
let room: String = env.get_string(&room_j).map(|s| s.into()).unwrap_or_default(); let room: String = env.get_string(&room_j).map(|s| s.into()).unwrap_or_default();
let seed_hex: String = env.get_string(&seed_hex_j).map(|s| s.into()).unwrap_or_default(); let seed_hex: String = env.get_string(&seed_hex_j).map(|s| s.into()).unwrap_or_default();
let token: String = env.get_string(&token_j).map(|s| s.into()).unwrap_or_default(); let token: String = env.get_string(&token_j).map(|s| s.into()).unwrap_or_default();
let alias: String = env.get_string(&alias_j).map(|s| s.into()).unwrap_or_default();
let h = unsafe { handle_ref(handle) }; let h = unsafe { handle_ref(handle) };
@@ -83,6 +115,7 @@ pub unsafe extern "system" fn Java_com_wzp_engine_WzpEngine_nativeStartCall(
room, room,
auth_token: if token.is_empty() { Vec::new() } else { token.into_bytes() }, auth_token: if token.is_empty() { Vec::new() } else { token.into_bytes() },
identity_seed, identity_seed,
alias: if alias.is_empty() { None } else { Some(alias) },
}; };
match h.engine.start_call(config) { match h.engine.start_call(config) {
@@ -190,7 +223,6 @@ pub unsafe extern "system" fn Java_com_wzp_engine_WzpEngine_nativeWriteAudio(
return 0; return 0;
} }
let mut buf = vec![0i16; len]; let mut buf = vec![0i16; len];
// GetShortArrayRegion copies Java array into our buffer
if env.get_short_array_region(&pcm, 0, &mut buf).is_err() { if env.get_short_array_region(&pcm, 0, &mut buf).is_err() {
return 0; return 0;
} }
@@ -224,6 +256,56 @@ pub unsafe extern "system" fn Java_com_wzp_engine_WzpEngine_nativeReadAudio(
result.unwrap_or(0) result.unwrap_or(0)
} }
/// Write captured PCM from a DirectByteBuffer — zero JNI array copies.
/// The ByteBuffer must contain little-endian i16 samples.
/// Called from the AudioRecord capture thread.
#[unsafe(no_mangle)]
pub unsafe extern "system" fn Java_com_wzp_engine_WzpEngine_nativeWriteAudioDirect(
env: JNIEnv,
_class: JClass,
handle: jlong,
buffer: jni::objects::JByteBuffer,
sample_count: jint,
) -> jint {
let result = panic::catch_unwind(panic::AssertUnwindSafe(|| {
let h = unsafe { handle_ref(handle) };
let ptr = env.get_direct_buffer_address(&buffer).unwrap_or(std::ptr::null_mut());
if ptr.is_null() || sample_count <= 0 {
return 0;
}
let samples = unsafe {
std::slice::from_raw_parts(ptr as *const i16, sample_count as usize)
};
h.engine.write_audio(samples) as jint
}));
result.unwrap_or(0)
}
/// Read decoded PCM into a DirectByteBuffer — zero JNI array copies.
/// The ByteBuffer will be filled with little-endian i16 samples.
/// Called from the AudioTrack playout thread.
#[unsafe(no_mangle)]
pub unsafe extern "system" fn Java_com_wzp_engine_WzpEngine_nativeReadAudioDirect(
env: JNIEnv,
_class: JClass,
handle: jlong,
buffer: jni::objects::JByteBuffer,
max_samples: jint,
) -> jint {
let result = panic::catch_unwind(panic::AssertUnwindSafe(|| {
let h = unsafe { handle_ref(handle) };
let ptr = env.get_direct_buffer_address(&buffer).unwrap_or(std::ptr::null_mut());
if ptr.is_null() || max_samples <= 0 {
return 0;
}
let samples = unsafe {
std::slice::from_raw_parts_mut(ptr as *mut i16, max_samples as usize)
};
h.engine.read_audio(samples) as jint
}));
result.unwrap_or(0)
}
#[unsafe(no_mangle)] #[unsafe(no_mangle)]
pub unsafe extern "system" fn Java_com_wzp_engine_WzpEngine_nativeDestroy( pub unsafe extern "system" fn Java_com_wzp_engine_WzpEngine_nativeDestroy(
_env: JNIEnv, _env: JNIEnv,
@@ -235,3 +317,79 @@ pub unsafe extern "system" fn Java_com_wzp_engine_WzpEngine_nativeDestroy(
drop(h); drop(h);
})); }));
} }
/// Ping a relay server — returns JSON `{"rtt_ms":N,"server_fingerprint":"hex"}` or null on failure.
/// Does NOT require an engine handle — creates a temporary QUIC connection.
#[unsafe(no_mangle)]
pub unsafe extern "system" fn Java_com_wzp_engine_WzpEngine_nativePingRelay<'a>(
mut env: JNIEnv<'a>,
_class: JClass,
relay_j: JString,
) -> jstring {
let result = panic::catch_unwind(panic::AssertUnwindSafe(|| {
let relay: String = env.get_string(&relay_j).map(|s| s.into()).unwrap_or_default();
let addr: std::net::SocketAddr = match relay.parse() {
Ok(a) => a,
Err(_) => return None,
};
let _ = rustls::crypto::ring::default_provider().install_default();
let rt = match tokio::runtime::Builder::new_current_thread()
.enable_all()
.build()
{
Ok(rt) => rt,
Err(_) => return None,
};
rt.block_on(async {
let bind: std::net::SocketAddr = "0.0.0.0:0".parse().unwrap();
let endpoint = match wzp_transport::create_endpoint(bind, None) {
Ok(e) => e,
Err(_) => return None,
};
let client_cfg = wzp_transport::client_config();
let start = std::time::Instant::now();
match tokio::time::timeout(
std::time::Duration::from_secs(3),
wzp_transport::connect(&endpoint, addr, "ping", client_cfg),
)
.await
{
Ok(Ok(conn)) => {
let rtt_ms = start.elapsed().as_millis() as u64;
let server_fp = conn
.peer_identity()
.and_then(|id| {
id.downcast::<Vec<rustls::pki_types::CertificateDer>>().ok()
})
.and_then(|certs| {
certs.first().map(|c| {
use std::hash::{Hash, Hasher};
let mut h = std::collections::hash_map::DefaultHasher::new();
c.as_ref().hash(&mut h);
format!("{:016x}", h.finish())
})
})
.unwrap_or_default();
conn.close(0u32.into(), b"ping");
Some(format!(
r#"{{"rtt_ms":{},"server_fingerprint":"{}"}}"#,
rtt_ms, server_fp
))
}
_ => None,
}
})
}));
let json = match result {
Ok(Some(s)) => s,
_ => return JObject::null().into_raw(),
};
env.new_string(&json)
.map(|s| s.into_raw())
.unwrap_or(JObject::null().into_raw())
}

View File

@@ -51,6 +51,12 @@ pub struct CallStats {
pub underruns: u64, pub underruns: u64,
/// Frames recovered by FEC. /// Frames recovered by FEC.
pub fec_recovered: u64, pub fec_recovered: u64,
/// Playout ring overflow count (reader was lapped by writer).
pub playout_overflows: u64,
/// Playout ring underrun count (reader found empty buffer).
pub playout_underruns: u64,
/// Capture ring overflow count.
pub capture_overflows: u64,
/// Current mic audio level (RMS of i16 samples, 0-32767). /// Current mic audio level (RMS of i16 samples, 0-32767).
pub audio_level: u32, pub audio_level: u32,
/// Number of participants in the room (from last RoomUpdate). /// Number of participants in the room (from last RoomUpdate).

View File

@@ -287,6 +287,7 @@ async fn main() -> anyhow::Result<()> {
let _crypto_session = wzp_client::handshake::perform_handshake( let _crypto_session = wzp_client::handshake::perform_handshake(
&*transport, &*transport,
&seed.0, &seed.0,
None, // alias — desktop client doesn't set one yet
).await?; ).await?;
info!("crypto handshake complete"); info!("crypto handshake complete");

View File

@@ -125,6 +125,7 @@ mod tests {
ephemeral_pub: [2u8; 32], ephemeral_pub: [2u8; 32],
signature: vec![3u8; 64], signature: vec![3u8; 64],
supported_profiles: vec![QualityProfile::GOOD], supported_profiles: vec![QualityProfile::GOOD],
alias: None,
}; };
let encoded = encode_call_payload(&signal, Some("relay.example.com:4433"), Some("myroom")); let encoded = encode_call_payload(&signal, Some("relay.example.com:4433"), Some("myroom"));
@@ -142,6 +143,7 @@ mod tests {
ephemeral_pub: [0; 32], ephemeral_pub: [0; 32],
signature: vec![], signature: vec![],
supported_profiles: vec![], supported_profiles: vec![],
alias: None,
}; };
assert!(matches!(signal_to_call_type(&offer), CallSignalType::Offer)); assert!(matches!(signal_to_call_type(&offer), CallSignalType::Offer));

View File

@@ -17,6 +17,7 @@ use wzp_proto::{MediaTransport, QualityProfile, SignalMessage};
pub async fn perform_handshake( pub async fn perform_handshake(
transport: &dyn MediaTransport, transport: &dyn MediaTransport,
seed: &[u8; 32], seed: &[u8; 32],
alias: Option<&str>,
) -> Result<Box<dyn CryptoSession>, anyhow::Error> { ) -> Result<Box<dyn CryptoSession>, anyhow::Error> {
// 1. Create key exchange from identity seed // 1. Create key exchange from identity seed
let mut kx = WarzoneKeyExchange::from_identity_seed(seed); let mut kx = WarzoneKeyExchange::from_identity_seed(seed);
@@ -41,6 +42,7 @@ pub async fn perform_handshake(
QualityProfile::DEGRADED, QualityProfile::DEGRADED,
QualityProfile::CATASTROPHIC, QualityProfile::CATASTROPHIC,
], ],
alias: alias.map(|s| s.to_string()),
}; };
transport.send_signal(&offer).await?; transport.send_signal(&offer).await?;

View File

@@ -548,6 +548,9 @@ pub enum SignalMessage {
signature: Vec<u8>, signature: Vec<u8>,
/// Supported quality profiles. /// Supported quality profiles.
supported_profiles: Vec<crate::QualityProfile>, supported_profiles: Vec<crate::QualityProfile>,
/// Optional display name set by the caller.
#[serde(default)]
alias: Option<String>,
}, },
/// Call acceptance (analogous to Warzone's WireMessage::CallAnswer). /// Call acceptance (analogous to Warzone's WireMessage::CallAnswer).

View File

@@ -15,25 +15,27 @@ use wzp_proto::{MediaTransport, QualityProfile, SignalMessage};
/// 5. Derive shared ChaCha20-Poly1305 session /// 5. Derive shared ChaCha20-Poly1305 session
/// 6. Send `CallAnswer` back /// 6. Send `CallAnswer` back
/// ///
/// Returns the derived `CryptoSession` and the chosen `QualityProfile`. /// Returns the derived `CryptoSession`, the chosen `QualityProfile`, the caller's fingerprint,
/// and the caller's alias (if provided in CallOffer).
pub async fn accept_handshake( pub async fn accept_handshake(
transport: &dyn MediaTransport, transport: &dyn MediaTransport,
seed: &[u8; 32], seed: &[u8; 32],
) -> Result<(Box<dyn CryptoSession>, QualityProfile), anyhow::Error> { ) -> Result<(Box<dyn CryptoSession>, QualityProfile, String, Option<String>), anyhow::Error> {
// 1. Receive CallOffer // 1. Receive CallOffer
let offer = transport let offer = transport
.recv_signal() .recv_signal()
.await? .await?
.ok_or_else(|| anyhow::anyhow!("connection closed before receiving CallOffer"))?; .ok_or_else(|| anyhow::anyhow!("connection closed before receiving CallOffer"))?;
let (caller_identity_pub, caller_ephemeral_pub, caller_signature, supported_profiles) = let (caller_identity_pub, caller_ephemeral_pub, caller_signature, supported_profiles, caller_alias) =
match offer { match offer {
SignalMessage::CallOffer { SignalMessage::CallOffer {
identity_pub, identity_pub,
ephemeral_pub, ephemeral_pub,
signature, signature,
supported_profiles, supported_profiles,
} => (identity_pub, ephemeral_pub, signature, supported_profiles), alias,
} => (identity_pub, ephemeral_pub, signature, supported_profiles, alias),
other => { other => {
return Err(anyhow::anyhow!( return Err(anyhow::anyhow!(
"expected CallOffer, got {:?}", "expected CallOffer, got {:?}",
@@ -76,7 +78,13 @@ pub async fn accept_handshake(
}; };
transport.send_signal(&answer).await?; transport.send_signal(&answer).await?;
Ok((session, chosen_profile)) // Derive caller fingerprint from their identity public key (first 8 bytes as hex)
let caller_fp = caller_identity_pub[..8]
.iter()
.map(|b| format!("{b:02x}"))
.collect::<String>();
Ok((session, chosen_profile, caller_fp, caller_alias))
} }
/// Select the best quality profile from those the caller supports. /// Select the best quality profile from those the caller supports.

View File

@@ -431,7 +431,7 @@ async fn main() -> anyhow::Result<()> {
// Crypto handshake: verify client identity + negotiate quality profile // Crypto handshake: verify client identity + negotiate quality profile
let handshake_start = std::time::Instant::now(); let handshake_start = std::time::Instant::now();
let (_crypto_session, _chosen_profile) = match wzp_relay::handshake::accept_handshake( let (_crypto_session, _chosen_profile, caller_fp, caller_alias) = match wzp_relay::handshake::accept_handshake(
&*transport, &*transport,
&relay_seed_bytes, &relay_seed_bytes,
).await { ).await {
@@ -448,10 +448,13 @@ async fn main() -> anyhow::Result<()> {
} }
}; };
// Use the caller's identity fingerprint from the handshake
let participant_fp = authenticated_fp.clone().unwrap_or(caller_fp);
// Register in presence registry // Register in presence registry
if let Some(ref fp) = authenticated_fp { {
let mut reg = presence.lock().await; let mut reg = presence.lock().await;
reg.register_local(fp, None, Some(room_name.clone())); reg.register_local(&participant_fp, None, Some(room_name.clone()));
} }
info!(%addr, room = %room_name, "client joining"); info!(%addr, room = %room_name, "client joining");
@@ -506,8 +509,8 @@ async fn main() -> anyhow::Result<()> {
&room_name, &room_name,
addr, addr,
room::ParticipantSender::Quic(transport.clone()), room::ParticipantSender::Quic(transport.clone()),
authenticated_fp.as_deref(), Some(&participant_fp),
None, // alias — TODO: accept from client caller_alias.as_deref(),
) { ) {
Ok((id, update, senders)) => { Ok((id, update, senders)) => {
metrics.active_rooms.set(mgr.list().len() as i64); metrics.active_rooms.set(mgr.list().len() as i64);

View File

@@ -10,7 +10,7 @@ use std::time::Duration;
use bytes::Bytes; use bytes::Bytes;
use tokio::sync::Mutex; use tokio::sync::Mutex;
use tracing::{error, info, warn}; use tracing::{debug, error, info, trace, warn};
use wzp_proto::packet::TrunkFrame; use wzp_proto::packet::TrunkFrame;
use wzp_proto::MediaTransport; use wzp_proto::MediaTransport;
@@ -375,55 +375,121 @@ async fn run_participant_plain(
) { ) {
let addr = transport.connection().remote_address(); let addr = transport.connection().remote_address();
let mut packets_forwarded = 0u64; let mut packets_forwarded = 0u64;
let mut last_recv_instant = std::time::Instant::now();
let mut max_recv_gap_ms = 0u64;
let mut max_forward_ms = 0u64;
let mut send_errors = 0u64;
let mut last_log_instant = std::time::Instant::now();
info!(
room = %room_name,
participant = participant_id,
%addr,
session = session_id,
"forwarding loop started (plain)"
);
loop { loop {
let recv_start = std::time::Instant::now();
let pkt = match transport.recv_media().await { let pkt = match transport.recv_media().await {
Ok(Some(pkt)) => pkt, Ok(Some(pkt)) => pkt,
Ok(None) => { Ok(None) => {
info!(%addr, participant = participant_id, "disconnected"); info!(%addr, participant = participant_id, forwarded = packets_forwarded, "disconnected (stream ended)");
break; break;
} }
Err(e) => { Err(e) => {
let msg = e.to_string(); let msg = e.to_string();
if msg.contains("timed out") || msg.contains("reset") || msg.contains("closed") { if msg.contains("timed out") || msg.contains("reset") || msg.contains("closed") {
info!(%addr, participant = participant_id, "connection closed: {e}"); info!(%addr, participant = participant_id, forwarded = packets_forwarded, "connection closed: {e}");
} else { } else {
error!(%addr, participant = participant_id, "recv error: {e}"); error!(%addr, participant = participant_id, forwarded = packets_forwarded, "recv error: {e}");
} }
break; break;
} }
}; };
let recv_gap_ms = last_recv_instant.elapsed().as_millis() as u64;
last_recv_instant = std::time::Instant::now();
if recv_gap_ms > max_recv_gap_ms {
max_recv_gap_ms = recv_gap_ms;
}
// Log if recv gap is suspiciously large (>200ms = missed ~10 packets)
if recv_gap_ms > 200 {
warn!(
room = %room_name,
participant = participant_id,
recv_gap_ms,
seq = pkt.header.seq,
"large recv gap"
);
}
// Update per-session quality metrics if a quality report is present // Update per-session quality metrics if a quality report is present
if let Some(ref report) = pkt.quality_report { if let Some(ref report) = pkt.quality_report {
metrics.update_session_quality(session_id, report); metrics.update_session_quality(session_id, report);
} }
// Get current list of other participants // Get current list of other participants
let lock_start = std::time::Instant::now();
let others = { let others = {
let mgr = room_mgr.lock().await; let mgr = room_mgr.lock().await;
mgr.others(&room_name, participant_id) mgr.others(&room_name, participant_id)
}; };
let lock_ms = lock_start.elapsed().as_millis() as u64;
if lock_ms > 10 {
warn!(
room = %room_name,
participant = participant_id,
lock_ms,
"slow room_mgr lock"
);
}
// Forward to all others // Forward to all others
let fwd_start = std::time::Instant::now();
let pkt_bytes = pkt.payload.len() as u64; let pkt_bytes = pkt.payload.len() as u64;
for other in &others { for other in &others {
match other { match other {
ParticipantSender::Quic(t) => { ParticipantSender::Quic(t) => {
let _ = t.send_media(&pkt).await; if let Err(e) = t.send_media(&pkt).await {
send_errors += 1;
if send_errors <= 5 || send_errors % 100 == 0 {
warn!(
room = %room_name,
participant = participant_id,
peer = %t.connection().remote_address(),
total_send_errors = send_errors,
"send_media error: {e}"
);
}
}
} }
ParticipantSender::WebSocket(_) => { ParticipantSender::WebSocket(_) => {
// WS clients receive raw payload bytes
let _ = other.send_raw(&pkt.payload).await; let _ = other.send_raw(&pkt.payload).await;
} }
} }
} }
let fwd_ms = fwd_start.elapsed().as_millis() as u64;
if fwd_ms > max_forward_ms {
max_forward_ms = fwd_ms;
}
if fwd_ms > 50 {
warn!(
room = %room_name,
participant = participant_id,
fwd_ms,
fan_out = others.len(),
"slow forward"
);
}
let fan_out = others.len() as u64; let fan_out = others.len() as u64;
metrics.packets_forwarded.inc_by(fan_out); metrics.packets_forwarded.inc_by(fan_out);
metrics.bytes_forwarded.inc_by(pkt_bytes * fan_out); metrics.bytes_forwarded.inc_by(pkt_bytes * fan_out);
packets_forwarded += 1; packets_forwarded += 1;
if packets_forwarded % 500 == 0 {
// Periodic stats log every 5 seconds
if last_log_instant.elapsed() >= Duration::from_secs(5) {
let room_size = { let room_size = {
let mgr = room_mgr.lock().await; let mgr = room_mgr.lock().await;
mgr.room_size(&room_name) mgr.room_size(&room_name)
@@ -433,8 +499,15 @@ async fn run_participant_plain(
participant = participant_id, participant = participant_id,
forwarded = packets_forwarded, forwarded = packets_forwarded,
room_size, room_size,
fan_out,
max_recv_gap_ms,
max_forward_ms,
send_errors,
"participant stats" "participant stats"
); );
max_recv_gap_ms = 0;
max_forward_ms = 0;
last_log_instant = std::time::Instant::now();
} }
} }
@@ -459,6 +532,19 @@ async fn run_participant_trunked(
let addr = transport.connection().remote_address(); let addr = transport.connection().remote_address();
let mut packets_forwarded = 0u64; let mut packets_forwarded = 0u64;
let mut last_recv_instant = std::time::Instant::now();
let mut max_recv_gap_ms = 0u64;
let mut max_forward_ms = 0u64;
let mut send_errors = 0u64;
let mut last_log_instant = std::time::Instant::now();
info!(
room = %room_name,
participant = participant_id,
%addr,
session = session_id,
"forwarding loop started (trunked)"
);
// Per-peer TrunkedForwarders, keyed by the raw pointer of the peer // Per-peer TrunkedForwarders, keyed by the raw pointer of the peer
// transport (stable for the Arc's lifetime). We use the remote address // transport (stable for the Arc's lifetime). We use the remote address
@@ -480,24 +566,50 @@ async fn run_participant_trunked(
let pkt = match result { let pkt = match result {
Ok(Some(pkt)) => pkt, Ok(Some(pkt)) => pkt,
Ok(None) => { Ok(None) => {
info!(%addr, participant = participant_id, "disconnected"); info!(%addr, participant = participant_id, forwarded = packets_forwarded, "disconnected (stream ended)");
break; break;
} }
Err(e) => { Err(e) => {
error!(%addr, participant = participant_id, "recv error: {e}"); error!(%addr, participant = participant_id, forwarded = packets_forwarded, "recv error: {e}");
break; break;
} }
}; };
let recv_gap_ms = last_recv_instant.elapsed().as_millis() as u64;
last_recv_instant = std::time::Instant::now();
if recv_gap_ms > max_recv_gap_ms {
max_recv_gap_ms = recv_gap_ms;
}
if recv_gap_ms > 200 {
warn!(
room = %room_name,
participant = participant_id,
recv_gap_ms,
seq = pkt.header.seq,
"large recv gap (trunked)"
);
}
if let Some(ref report) = pkt.quality_report { if let Some(ref report) = pkt.quality_report {
metrics.update_session_quality(session_id, report); metrics.update_session_quality(session_id, report);
} }
let lock_start = std::time::Instant::now();
let others = { let others = {
let mgr = room_mgr.lock().await; let mgr = room_mgr.lock().await;
mgr.others(&room_name, participant_id) mgr.others(&room_name, participant_id)
}; };
let lock_ms = lock_start.elapsed().as_millis() as u64;
if lock_ms > 10 {
warn!(
room = %room_name,
participant = participant_id,
lock_ms,
"slow room_mgr lock (trunked)"
);
}
let fwd_start = std::time::Instant::now();
let pkt_bytes = pkt.payload.len() as u64; let pkt_bytes = pkt.payload.len() as u64;
for other in &others { for other in &others {
match other { match other {
@@ -507,21 +619,44 @@ async fn run_participant_trunked(
.entry(peer_addr) .entry(peer_addr)
.or_insert_with(|| TrunkedForwarder::new(t.clone(), sid_bytes)); .or_insert_with(|| TrunkedForwarder::new(t.clone(), sid_bytes));
if let Err(e) = fwd.send(&pkt).await { if let Err(e) = fwd.send(&pkt).await {
let _ = e; send_errors += 1;
if send_errors <= 5 || send_errors % 100 == 0 {
warn!(
room = %room_name,
participant = participant_id,
peer = %peer_addr,
total_send_errors = send_errors,
"trunked send error: {e}"
);
}
} }
} }
ParticipantSender::WebSocket(_) => { ParticipantSender::WebSocket(_) => {
// WS clients bypass trunking — send raw payload directly
let _ = other.send_raw(&pkt.payload).await; let _ = other.send_raw(&pkt.payload).await;
} }
} }
} }
let fwd_ms = fwd_start.elapsed().as_millis() as u64;
if fwd_ms > max_forward_ms {
max_forward_ms = fwd_ms;
}
if fwd_ms > 50 {
warn!(
room = %room_name,
participant = participant_id,
fwd_ms,
fan_out = others.len(),
"slow forward (trunked)"
);
}
let fan_out = others.len() as u64; let fan_out = others.len() as u64;
metrics.packets_forwarded.inc_by(fan_out); metrics.packets_forwarded.inc_by(fan_out);
metrics.bytes_forwarded.inc_by(pkt_bytes * fan_out); metrics.bytes_forwarded.inc_by(pkt_bytes * fan_out);
packets_forwarded += 1; packets_forwarded += 1;
if packets_forwarded % 500 == 0 {
// Periodic stats every 5 seconds
if last_log_instant.elapsed() >= Duration::from_secs(5) {
let room_size = { let room_size = {
let mgr = room_mgr.lock().await; let mgr = room_mgr.lock().await;
mgr.room_size(&room_name) mgr.room_size(&room_name)
@@ -531,15 +666,30 @@ async fn run_participant_trunked(
participant = participant_id, participant = participant_id,
forwarded = packets_forwarded, forwarded = packets_forwarded,
room_size, room_size,
fan_out,
max_recv_gap_ms,
max_forward_ms,
send_errors,
"participant stats (trunked)" "participant stats (trunked)"
); );
max_recv_gap_ms = 0;
max_forward_ms = 0;
last_log_instant = std::time::Instant::now();
} }
} }
_ = flush_interval.tick() => { _ = flush_interval.tick() => {
for fwd in forwarders.values_mut() { for fwd in forwarders.values_mut() {
if let Err(e) = fwd.flush().await { if let Err(e) = fwd.flush().await {
let _ = e; send_errors += 1;
if send_errors <= 5 || send_errors % 100 == 0 {
warn!(
room = %room_name,
participant = participant_id,
total_send_errors = send_errors,
"trunk flush error: {e}"
);
}
} }
} }
} }

View File

@@ -33,6 +33,12 @@ impl QuinnTransport {
&self.connection &self.connection
} }
/// Close the QUIC connection immediately (synchronous, no async needed).
/// The relay will detect the close and remove this participant from the room.
pub fn close_now(&self) {
self.connection.close(quinn::VarInt::from_u32(0), b"hangup");
}
/// Feed an external RTT observation (e.g. from QUIC path stats) into the path monitor. /// Feed an external RTT observation (e.g. from QUIC path stats) into the path monitor.
pub fn feed_rtt(&self, rtt_ms: u32) { pub fn feed_rtt(&self, rtt_ms: u32) {
self.path_monitor.lock().unwrap().observe_rtt(rtt_ms); self.path_monitor.lock().unwrap().observe_rtt(rtt_ms);

View File

@@ -272,7 +272,7 @@ async fn handle_ws(socket: WebSocket, room: String, state: AppState) {
// Crypto handshake with relay // Crypto handshake with relay
let handshake_start = std::time::Instant::now(); let handshake_start = std::time::Instant::now();
let bridge_seed = wzp_crypto::Seed::generate(); let bridge_seed = wzp_crypto::Seed::generate();
match wzp_client::handshake::perform_handshake(&*transport, &bridge_seed.0).await { match wzp_client::handshake::perform_handshake(&*transport, &bridge_seed.0, None).await {
Ok(_session) => { Ok(_session) => {
let elapsed = handshake_start.elapsed().as_secs_f64(); let elapsed = handshake_start.elapsed().as_secs_f64();
state.metrics.handshake_latency.observe(elapsed); state.metrics.handshake_latency.observe(elapsed);

View File

@@ -0,0 +1,115 @@
# Incident Report: SIGBUS in ART GC During Audio Thread JNI Calls
**Date:** 2026-04-06
**Severity:** High — app crash (SIGBUS) mid-call
**Status:** Root-caused, fix proposed
**Affects:** Android 16 (API 36) devices with concurrent mark-compact GC
## Summary
The app crashes with SIGBUS (signal 7, BUS_ADRERR) during an active call. The crash occurs in ART's garbage collector or JIT compiler, NOT in our Rust native code or AudioRing buffer. Both `wzp-capture` and `wzp-playout` Kotlin threads are affected.
## Crash Details
### Crash 1: wzp-capture (18:42, after 476s of call)
```
Fatal signal 7 (SIGBUS), code 2 (BUS_ADRERR), fault addr 0x720009be38
tid 19697 (wzp-capture), pid 17885 (com.wzp.phone)
```
**Backtrace:**
```
#00 art::StackVisitor::WalkStack
#01 art::Thread::VisitRoots
#02 art::gc::collector::MarkCompact::ThreadFlipVisitor::Run
#03 art::Thread::EnsureFlipFunctionStarted
#04 CheckJNI::ReleasePrimitiveArrayElements ← JNI boundary
#05 android_media_AudioRecord_readInArray ← AudioRecord.read()
#09 com.wzp.audio.AudioPipeline.runCapture
```
**Root cause:** ART's concurrent mark-compact GC (`MarkCompact::ThreadFlipVisitor`) is flipping thread roots while the capture thread is in the middle of a JNI call (`AudioRecord.read()`). The GC's `EnsureFlipFunctionStarted` triggers a stack walk that hits an invalid address.
### Crash 2: wzp-playout (19:17, mid-call)
```
Fatal signal 7 (SIGBUS), code 2 (BUS_ADRERR), fault addr 0x225eb98
tid 32574 (wzp-playout), pid 32479 (com.wzp.phone)
```
**Backtrace:**
```
#00 com.wzp.audio.AudioPipeline.runPlayout ← JIT-compiled code
#01 art_quick_osr_stub ← On-Stack Replacement
#02 art::jit::Jit::MaybeDoOnStackReplacement
#03-#04 art::interpreter::ExecuteSwitchImplCpp
```
**Root cause:** ART's JIT compiler performed On-Stack Replacement (OSR) on the hot playout loop. The OSR stub references a code address (`0x225eb98`) that is no longer valid — likely because the GC moved the compiled code in memory during concurrent compaction.
## Why This Happens
Android 16 introduced a new **concurrent mark-compact GC** (CMC) that moves objects in memory while other threads are running. This is safe for normal Java code because ART uses read barriers. But our audio threads have specific properties that stress this:
1. **`Thread.MAX_PRIORITY`** — audio threads run at the highest priority, starving the GC thread of CPU time. The GC may not complete its thread-flip before the audio thread resumes.
2. **Tight JNI loops**`runCapture()` and `runPlayout()` loop every 20ms calling `AudioRecord.read()` / `AudioTrack.write()` via JNI. Each JNI transition is a GC safepoint, but the thread spends most of its time in native code where the GC can't flip it.
3. **Long-running JIT-compiled code** — the hot loop gets JIT-compiled and may undergo OSR. If the GC compacts memory while OSR is in progress, the stub can reference stale addresses.
4. **Daemon threads that never exit** — our threads are parked with `Thread.sleep(Long.MAX_VALUE)` after the call ends (to avoid the libcrypto TLS destructor crash). These zombie threads accumulate GC root scan work.
## Evidence This Is Not Our Bug
| Component | Evidence |
|-----------|---------|
| **AudioRing** | Not in any backtrace. All crash frames are in `libart.so` (ART runtime) |
| **Rust native code** | `libwzp_android.so` not in any crash frame |
| **JNI bridge** | Crash happens during `ReleasePrimitiveArrayElements` (ART internal), not during our JNI calls |
| **Timing** | Crashes after 476s and mid-call — not during init or teardown |
## Proposed Fix
### Option A: Disable concurrent GC compaction for audio threads (recommended)
Use `dalvik.vm.gctype` or per-thread GC pinning to prevent the mark-compact collector from moving objects referenced by audio threads.
**Not directly controllable from app code.** But we can reduce GC pressure:
### Option B: Reduce JNI transitions in audio threads
Instead of calling `engine.writeAudio(pcm)` / `engine.readAudio(pcm)` via JNI on every 20ms frame, batch multiple frames or use `DirectByteBuffer` to share memory without JNI array copies.
**Implementation:**
- Allocate a `DirectByteBuffer` in Kotlin, share the pointer with Rust via JNI
- Audio threads write/read directly to the buffer (no JNI call per frame)
- Rust reads/writes from the same memory region
- Reduces JNI transitions from 100/sec to 0/sec per audio direction
### Option C: Use Android's Oboe (AAudio) natively from Rust
Skip the Kotlin AudioRecord/AudioTrack entirely. Use Oboe (which we already have as a dependency in `wzp-android/Cargo.toml`) to create native audio streams directly from Rust. The audio callbacks run in native code with no JNI, no GC interaction, no ART.
This is how the project was originally designed (see `audio_android.rs` with Oboe references) before switching to Kotlin AudioRecord for simplicity.
**Pros:** Eliminates the entire JNI audio path. No GC interaction. Lower latency.
**Cons:** Requires rewriting `AudioPipeline.kt` into Rust. Oboe setup is more complex.
### Option D: Pin audio thread objects to prevent GC movement
Use JNI `GetPrimitiveArrayCritical` instead of `GetShortArrayRegion` to pin the array in memory during the operation. This prevents the GC from moving the array while we're using it.
**Implementation:** Change `nativeWriteAudio` / `nativeReadAudio` JNI functions to use critical sections.
### Recommendation
**Short term: Option B** (DirectByteBuffer) — reduces JNI transitions without major refactoring.
**Long term: Option C** (Oboe from Rust) — eliminates the problem entirely. This is the architecturally correct solution and matches the original design intent.
## Data Files
- Logcat from Nothing A059 (Android 16, API 36)
- Two crashes in the same session: 18:42 (capture, after 476s) and 19:17 (playout)
- Both SIGBUS/BUS_ADRERR, both in ART internal frames

View File

@@ -0,0 +1,175 @@
# Incident Report: Native Crash in Capture Thread — Use-After-Free on Engine Handle
**Date:** 2026-04-06
**Severity:** Critical — app crash (SIGSEGV) on call hangup
**Status:** Root-caused, fix pending
**Affects:** Android client only
## Summary
The app crashes with a native SIGSEGV during or shortly after call hangup. The crash occurs in JIT-compiled code inside `AudioPipeline.runCapture()`. The root cause is a use-after-free: the capture thread calls `engine.writeAudio()` via JNI after the engine's native handle has been freed by `teardown()` on the ViewModel thread.
## Crash Stacktrace
```
04-06 13:05:42.707 F DEBUG: #09 pc 000000000250696c /memfd:jit-cache (deleted) (com.wzp.audio.AudioPipeline.runCapture+3228)
04-06 13:05:42.707 F DEBUG: #14 pc 0000000000005270 <anonymous:730900d000> (com.wzp.audio.AudioPipeline.start$lambda$0+0)
04-06 13:05:42.708 F DEBUG: #19 pc 00000000000044cc <anonymous:730900d000> (com.wzp.audio.AudioPipeline.$r8$lambda$0rYcivupwvyN4SgBXhsroKmTlo8+0)
04-06 13:05:42.708 F DEBUG: #24 pc 00000000000042e4 <anonymous:730900d000> (com.wzp.audio.AudioPipeline$$ExternalSyntheticLambda0.run+0)
```
This is a tombstone (signal crash), not a Java exception. The `F DEBUG` tag indicates a native crash handler (debuggerd) captured the signal.
## Root Cause
### The Race Condition
Two threads operate on the engine concurrently without synchronization:
**Thread 1: `wzp-capture` (AudioRecord thread, MAX_PRIORITY)**
```kotlin
// AudioPipeline.runCapture() — runs in a tight loop
while (running) {
val read = recorder.read(pcm, 0, FRAME_SAMPLES)
if (read > 0) {
engine.writeAudio(pcm) // <-- JNI call to native engine
}
}
```
**Thread 2: ViewModel/UI thread (normal priority)**
```kotlin
// CallViewModel.teardown()
stopAudio() // sets AudioPipeline.running = false
engine?.stopCall() // tells Rust to stop
engine?.destroy() // frees native memory, sets nativeHandle = 0L
engine = null
```
### The Kotlin Guard is Insufficient
`WzpEngine.writeAudio()` has a guard:
```kotlin
fun writeAudio(pcm: ShortArray): Int {
if (nativeHandle == 0L) return 0 // check
return nativeWriteAudio(nativeHandle, pcm) // use
}
```
This is a **TOCTOU (time-of-check/time-of-use) race**:
1. Capture thread checks `nativeHandle != 0L` → true
2. ViewModel thread calls `destroy()`, which calls `nativeDestroy(handle)` then sets `nativeHandle = 0L`
3. Capture thread calls `nativeWriteAudio(handle, pcm)` with the now-freed handle
4. The JNI function dereferences `handle` as a pointer → **SIGSEGV**
The same race exists for `readAudio()` on the `wzp-playout` thread.
### Why `stopAudio()` Doesn't Prevent This
`AudioPipeline.stop()` sets `running = false` but does **NOT join or wait** for the threads:
```kotlin
fun stop() {
running = false
// Don't join — threads are parked as daemons to avoid native TLS crash
captureThread = null
playoutThread = null
}
```
The threads are intentionally not joined because of a separate bug: exiting a JNI-calling thread triggers a `SIGSEGV in OPENSSL_free` due to libcrypto TLS destructors on Android. The threads instead "park" with `Thread.sleep(Long.MAX_VALUE)` after the loop exits.
But the problem is the **window between `running = false` and the thread actually checking it**. The capture thread may be blocked in `recorder.read()` (which blocks for 20ms per frame) or in the middle of `engine.writeAudio()` when `destroy()` is called.
### Timeline of the Crash
```
T=0ms ViewModel: stopAudio() → sets running=false
T=0ms ViewModel: stopStatsPolling()
T=0ms ViewModel: engine.stopCall() — Rust stops internal tasks
T=1ms ViewModel: engine.destroy() — frees native memory
↑ nativeHandle = 0L
T=0-20ms Capture thread: still in recorder.read() or writeAudio()
→ if in writeAudio(), the nativeHandle check passed BEFORE destroy()
→ JNI dereferences freed pointer → SIGSEGV
```
## Affected Code
### Files with the race
| File | Line(s) | Issue |
|------|---------|-------|
| `android/.../WzpEngine.kt` | 107-108, 116-117 | TOCTOU on `nativeHandle` in `writeAudio()` / `readAudio()` |
| `android/.../CallViewModel.kt` | 257-262 | `stopAudio()` + `destroy()` without waiting for audio threads to quiesce |
| `android/.../AudioPipeline.kt` | 80-82 | `stop()` doesn't synchronize with running threads |
### Files with the thread parking workaround
| File | Line(s) | Context |
|------|---------|---------|
| `android/.../AudioPipeline.kt` | 57-58, 69-70 | Threads parked after loop exit to avoid libcrypto TLS crash |
| `android/.../AudioPipeline.kt` | 96-101 | `parkThread()``Thread.sleep(Long.MAX_VALUE)` |
## Constraints for the Fix
1. **Cannot join audio threads** — joining triggers a separate SIGSEGV in `OPENSSL_free` when the thread's TLS destructors fire (documented in `AudioPipeline.kt` comments). The parking workaround must be preserved.
2. **Must guarantee no JNI calls after `destroy()`** — the native handle is a raw pointer; any dereference after free is undefined behavior.
3. **Must not add blocking waits on the UI thread**`teardown()` runs on the ViewModel thread which must remain responsive.
4. **The `@Volatile running` flag is necessary but not sufficient** — it prevents new loop iterations but doesn't help with in-flight JNI calls.
5. **Both `writeAudio` and `readAudio` have the same race** — the fix must cover both the capture and playout paths.
## Reproduction
The crash is timing-dependent. It's most likely to occur when:
- The capture thread is in the middle of a `writeAudio()` JNI call when `destroy()` is called
- More likely on slower devices or under CPU pressure (GC, thermal throttling)
- Can happen on every hangup, but only crashes ~10-30% of the time due to the timing window
## Analysis of Possible Fix Approaches
### Approach A: Add a synchronization gate in the JNI bridge
Use a `ReentrantReadWriteLock` or `AtomicBoolean` in `WzpEngine.kt`:
- Audio threads acquire a read lock / check the flag before JNI calls
- `destroy()` acquires a write lock / sets the flag and waits for in-flight calls to drain
**Pro:** Clean, solves the race directly.
**Con:** Adding a lock to the audio hot path (every 20ms). `ReentrantReadWriteLock` is not lock-free. However, the read-lock path is uncontended 99.99% of the time (write-lock only during destroy), so contention is negligible.
### Approach B: Defer `destroy()` until audio threads have stopped
Instead of calling `destroy()` in `teardown()`, set a flag and have the audio threads call `destroy()` after they exit the loop (before parking).
**Pro:** No locks on hot path.
**Con:** Complex lifecycle — which thread calls destroy? What if both threads race to destroy? Need a `CountDownLatch` or similar.
### Approach C: Make the JNI handle atomically invalidated
Use `AtomicLong` for `nativeHandle` and use `compareAndExchange` in `destroy()` + `getAndCheck` pattern in audio calls.
**Pro:** Lock-free.
**Con:** Still has a TOCTOU window — the thread can load the handle, then it gets CAS'd to 0, then the thread uses the stale handle. Doesn't fully solve the race without combining with a reference count or epoch.
### Approach D: Introduce a destroy latch
Add a `CountDownLatch(1)` that audio threads wait on before parking. `teardown()` sets `running=false`, then `await`s the latch (with timeout), then calls `destroy()`. Each audio thread counts down the latch after exiting the loop.
Actually this needs a `CountDownLatch(2)` — one for each thread (capture + playout).
**Pro:** Guarantees no in-flight JNI calls at destroy time. No locks on hot path.
**Con:** `teardown()` blocks for up to one frame duration (~20ms) waiting for threads to exit their loops. Acceptable for a hangup path.
### Recommendation
**Approach D (destroy latch)** is the cleanest. The 20ms worst-case wait is imperceptible on the hangup path, and it provides a hard guarantee that no JNI calls are in flight when `destroy()` runs. Combined with the existing `running` volatile flag, the audio threads exit their loops within one frame and count down the latch.
If the latch times out (e.g., AudioRecord.read() is stuck), `destroy()` proceeds anyway — the `panic::catch_unwind` in the JNI bridge will catch the invalid access as a panic rather than a SIGSEGV (though this is best-effort; a true SIGSEGV from freed memory is not catchable).
## Data Files
The crash was captured from the Nothing A059 device at 13:05:42 on 2026-04-06. The tombstone is in the device's `/data/tombstones/` directory. The logcat output shows the crash frames.

View File

@@ -0,0 +1,166 @@
# Incident Report: Playout Ring Buffer Cursor Desync — Bidirectional Audio Loss
**Date:** 2026-04-06
**Severity:** Critical — causes 10-16 seconds of complete bidirectional silence mid-call
**Status:** Root-caused, fix pending
**Affects:** All clients using `AudioRing` (Android, potentially desktop)
## Summary
Both participants in a call experience simultaneous, prolonged audio silence (10-16 seconds) despite the QUIC transport, relay, and Rust codec pipeline all functioning normally. The root cause is a cursor desynchronization in the lock-free SPSC ring buffer (`AudioRing`) that transfers decoded PCM from the Rust recv task to the Kotlin AudioTrack playout thread.
## How We Know It's the Ring Buffer
### Evidence that eliminates other components
| Component | Evidence it's healthy | Source |
|-----------|----------------------|--------|
| **QUIC send path** | `frames_dropped=0, send_errors=0` on both clients | Engine send stats log |
| **QUIC recv path** | `max_recv_gap_ms=82, recv_errors=0` — no gaps >82ms | Engine recv stats log |
| **Relay forwarding** | `max_forward_ms=0, send_errors=0` in previous relay-instrumented test | Relay debug logging |
| **Opus codec** | `frames_decoded=2442` over 51.9s = 47 frames/sec (correct for 20ms) | Final stats JSON |
| **FEC** | `fec_recovered=4870` — FEC working normally | Final stats JSON |
| **Audio capture** | Pixel 6 capture has 0% silence; Nothing has gaps but those are expected mic pauses | capture_rms.csv |
### Evidence pointing to the ring buffer
1. **Both clients go silent at the exact same wall-clock moment (26.66s into call)** — rules out per-device issues; the common factor is the relay, but the relay was proven healthy in prior tests.
2. **`playout_avail=8640` at stats dump time** — the playout ring reports 8640 samples available (180ms, nearly full at the 9600 capacity). The recv task believes it has successfully written data into the ring. But the AudioTrack playout thread is reading silence (RMS=0 for 12+ seconds).
3. **Recv task continued receiving packets with no gaps**`max_recv_gap_ms=82` across the entire call. The decoded audio was written to the ring continuously.
4. **Silence starts and ends cleanly** — the transition from audio → silence happens within a single 20ms frame (frame 1332: rms=101, frame 1333: rms=0). This is not network degradation (which shows gradual quality loss). It's a discrete state change — the reader suddenly stops seeing data.
5. **Recovery is also discrete** — at ~38.8s (Sharp Hawk) and ~42.7s (Pixel 6), audio snaps back with high-energy frames (rms=3296+). Not a gradual reconnection.
## The Ring Buffer Code
**File:** `crates/wzp-android/src/audio_ring.rs`
```rust
const RING_CAPACITY: usize = 960 * 10; // 9600 samples = 200ms at 48kHz
pub struct AudioRing {
buf: Box<[i16; RING_CAPACITY]>,
write_pos: AtomicUsize, // monotonically increasing, wraps at usize::MAX
read_pos: AtomicUsize, // monotonically increasing, wraps at usize::MAX
}
```
### `available()` — how many samples can be read
```rust
pub fn available(&self) -> usize {
let w = self.write_pos.load(Ordering::Acquire);
let r = self.read_pos.load(Ordering::Acquire);
w.wrapping_sub(r) // relies on usize wrapping arithmetic
}
```
### `write()` — producer (Rust recv task thread, inside tokio block_on)
```rust
pub fn write(&self, samples: &[i16]) -> usize {
let w = self.write_pos.load(Ordering::Relaxed);
let count = samples.len().min(RING_CAPACITY);
// ... write samples at (w + i) % RING_CAPACITY ...
self.write_pos.store(w.wrapping_add(count), Ordering::Release);
// If we overwrote unread data, advance read_pos
if self.available() > RING_CAPACITY {
let new_read = self.write_pos.load(Ordering::Relaxed).wrapping_sub(RING_CAPACITY);
self.read_pos.store(new_read, Ordering::Release);
}
}
```
### `read()` — consumer (Kotlin AudioTrack JVM thread, via JNI)
```rust
pub fn read(&self, out: &mut [i16]) -> usize {
let avail = self.available();
let count = out.len().min(avail);
let r = self.read_pos.load(Ordering::Relaxed);
// ... read samples at (r + i) % RING_CAPACITY ...
self.read_pos.store(r.wrapping_add(count), Ordering::Release);
count
}
```
## Suspected Failure Modes
### 1. Writer advances `read_pos` while reader is mid-read (data race)
The `write()` method at lines 68-72 modifies `read_pos` from the writer thread when it detects overflow. But the `read()` method on the consumer thread also modifies `read_pos`. This violates the SPSC contract — `read_pos` is supposed to be owned by the consumer.
**Scenario:**
1. Reader loads `read_pos = R` (line 82)
2. Writer detects overflow, stores `read_pos = R'` (line 71) where `R' > R`
3. Reader finishes reading, stores `read_pos = R + count` (line 88) — **overwrites** the writer's `R'` with a stale, smaller value
After step 3, the ring's `read_pos` has gone backwards. Now `available()` returns `write_pos.wrapping_sub(old_read_pos)` which is larger than `RING_CAPACITY`. Every subsequent `write()` call hits the overflow branch and keeps advancing `read_pos`, but the reader keeps overwriting it back. The ring is in a corrupted state where the reader and writer are fighting over `read_pos`.
### 2. `wrapping_sub` returns astronomically large values
`available()` uses `w.wrapping_sub(r)`. On a 64-bit platform, if due to the race above `r > w`, `wrapping_sub` returns `usize::MAX - (r - w) + 1` — an enormous number. The `read()` method caps this with `out.len().min(avail)` so it reads `out.len()` samples. But those samples are from indices calculated as `(r + i) % RING_CAPACITY` which wraps correctly. The samples read would be whatever was in the buffer at those positions — potentially stale/old data, or zeros from initialization.
However, the playout RMS CSV shows clean zeros (RMS=0), not garbage. This suggests the ring is returning the zeroed-out initial buffer contents, meaning `read_pos` has jumped far ahead of `write_pos`, pointing to memory that was never written to (or was written long ago and has since been zeroed by the overflow advance logic).
### 3. Why silence lasts exactly 12-16 seconds
After the desync, each `write()` call (every 20ms when a packet is decoded) enters the overflow branch and resets `read_pos`. But the reader immediately overwrites it back in its next `read()` call. This tug-of-war continues until one of two things happens:
- The cursors happen to realign through wrapping arithmetic
- A timing coincidence where the writer's store to `read_pos` happens to "win" the race
The 12-16 second duration is non-deterministic and depends on exact thread scheduling and cursor values.
## Reproduction Pattern
The bug manifests after roughly 25-30 seconds of a call. This timing is suspicious:
- At 48kHz mono, 20ms frames = 50 frames/sec
- Each decoded frame writes 960 samples to the ring
- After 25 seconds: `write_pos ≈ 25 * 50 * 960 = 1,200,000`
- The ring capacity is 9600, so `write_pos` has wrapped around `RING_CAPACITY` about 125 times
The wrapping of the monotonic cursors past certain thresholds, combined with the reader/writer `read_pos` race, likely triggers the desync at this scale.
## Data Files
All data from two independent test sessions (3 calls total) is in `/workspace/wzp/debug/`:
| File | Contents |
|------|----------|
| `wzp_debug_20260406_120546.zip` | Sharp Hawk (Nothing A059) — 51.9s call |
| `wzp_debug_20260406_120549.zip` | Bright Viper (Pixel 6) — 51.9s call |
| `wzp_debug_20260406_111733.zip` | Sharp Hawk — earlier 72.0s call, same pattern |
| `wzp_debug_20260406_111735.zip` | Bright Viper — earlier 72.0s call, same pattern |
| `wzp_debug_20260406_105858.zip` | First session (pre-logging fix), 39.8s call |
| `wzp_debug_20260406_105900.zip` | First session, 39.7s call |
### Key fields in each zip
- `meta.txt` — device, duration, final stats JSON
- `playout_rms.csv` — per-frame (20ms) RMS of AudioTrack output; silence = RMS 0
- `capture_rms.csv` — per-frame RMS of AudioRecord input
- `logcat.txt` — Android logcat filtered to WZP + audio tags
### How to reproduce the analysis
```python
import csv
with open("playout_rms.csv") as f:
for row in csv.DictReader(f):
if int(row['rms']) == 0 and int(row['time_ms']) > 2000:
print(f"SILENCE at {row['time_ms']}ms")
```
## Affected Code
- `crates/wzp-android/src/audio_ring.rs` — the `AudioRing` struct, specifically the `write()` method's overflow handling that mutates `read_pos` from the producer thread
- Any client using `AudioRing` for playout (currently only Android; desktop uses `cpal` directly)
## Constraints for the Fix
1. Must remain lock-free — AudioTrack thread runs at `Thread.MAX_PRIORITY` and cannot block
2. Must handle overflow gracefully — if the reader falls behind, old audio should be dropped, not cause a desync
3. The writer (Rust recv task) and reader (Kotlin AudioTrack via JNI) run on different threads with different scheduling priorities
4. Ring capacity is 200ms which is tight — any fix must not increase latency significantly
5. The `write_pos` and `read_pos` are the only synchronization mechanism (no mutex, no condvar)

View File

@@ -0,0 +1,123 @@
# Incident Report: Send Task Fatal Exit on QUIC Congestion
**Date:** 2026-04-06
**Severity:** High — causes complete audio loss mid-call
**Status:** Fixed in Android client, **pending fix in desktop client and web client**
## Summary
A QUIC congestion event causes `send_datagram()` to return `Err(Blocked)`. The send task treats this as a fatal error and exits, which kills the entire call via `tokio::select!`. Audio becomes one-way (recv still works briefly) then dies completely.
## Root Cause
In the engine's send loop (`run_call` function), `transport.send_media()` errors were handled with `break`:
```rust
// BEFORE (broken)
if let Err(e) = transport.send_media(&source_pkt).await {
error!("send error: {e}");
break; // <-- kills send task, which kills everything
}
```
Quinn's `send_datagram()` is synchronous and returns `Err(SendDatagramError::Blocked)` when the QUIC congestion window is full. This is a **transient condition** — the window opens again once ACKs arrive. But the `break` kills the send task, and since all tasks run under `tokio::select!`, the recv task, stats task, and signal task all die too.
### Why it manifests as "intermittent disconnections"
- Mobile networks have brief congestion spikes (cell tower handoff, WiFi interference)
- A single spike fills the QUIC congestion window
- One `Blocked` error → send task exits → `select!` cancels recv → complete silence
- The QUIC connection stays open (no error logged), so stats polling continues showing stale data
- From the user's perspective: audio drops for 5-20 seconds then "maybe comes back" (it doesn't — they're hearing cached playout ring drain)
### Evidence from debug reports
**Relay logs** confirmed the relay was healthy:
- `max_forward_ms=0` — relay forwards instantly
- `send_errors=0` — no relay-side failures
- The relay saw `large recv gap` warnings on participant 1 (Nothing A059): 722ms → 814ms → 1778ms → 3500ms → 6091ms — the client progressively stopped sending
**Client stats** confirmed:
- `frames_encoded` kept incrementing (Opus encoder running)
- `frames_decoded` froze at a fixed value (recv task died)
- `fec_recovered` froze simultaneously
- RTT, loss, jitter all frozen (stats task died)
## Fix Applied
### Android client (`crates/wzp-android/src/engine.rs`)
```rust
// AFTER (fixed)
if let Err(e) = transport.send_media(&source_pkt).await {
send_errors += 1;
frames_dropped += 1;
if send_errors <= 3 || last_send_error_log.elapsed().as_secs() >= 1 {
warn!(seq = s, send_errors, frames_dropped,
"send_media error (dropping packet): {e}");
last_send_error_log = Instant::now();
}
continue; // <-- drop packet, keep going
}
```
Same pattern applied to FEC repair packet sends.
Recv task also hardened: transient errors (non-closed/reset) are now logged and survived rather than causing exit.
Added periodic health logging to both tasks (5-second intervals):
- Send: `frames_sent`, `frames_dropped`, `send_errors`, `ring_avail`
- Recv: `frames_decoded`, `fec_recovered`, `recv_errors`, `max_recv_gap_ms`, `playout_avail`
### Relay (`crates/wzp-relay/src/room.rs`)
Added debug logging to both plain and trunked forwarding loops:
- Per-recv gap tracking (warns on >200ms gaps)
- Room manager lock contention tracking (warns on >10ms)
- Forward latency tracking (warns on >50ms)
- Send error counting with peer identification
- 5-second periodic stats with all above metrics
## Affected Clients — FIX REQUIRED
### Desktop client (`crates/wzp-client/src/cli.rs`)
**Lines 345-348:**
```rust
if let Err(e) = transport.send_media(pkt).await {
error!("send error: {e}");
break; // <-- SAME BUG
}
```
**Lines 431-434:**
```rust
if let Err(e) = send_transport.send_media(pkt).await {
error!("send error: {e}");
return; // <-- SAME BUG
}
```
Both need the same continue-on-error pattern.
### Web client (`crates/wzp-web/src/main.rs`)
Needs audit — WebSocket transport may have different error semantics but same pattern should be checked.
## Testing
After fix, a congestion event will:
1. Log warnings with packet counts: `send_media error (dropping packet): Blocked`
2. Drop affected packets (brief audio glitch — ~20-100ms)
3. Resume normal sending once congestion window opens
4. FEC on the receiver side will recover most dropped packets
5. Call continues uninterrupted
## Timeline
- 10:37 — First crash observed (LinearProgressIndicator compose bug masked investigation)
- 10:58 — Debug reports collected, decoded stall pattern identified
- 11:16 — Relay debug logging deployed, confirmed relay is clean
- 11:17 — Second debug reports collected, send gaps correlated with relay recv gaps
- 11:30 — Root cause identified: `break` on `send_media` error in send task
- 11:45 — Fix applied and deployed

View File

@@ -0,0 +1,394 @@
# Fix: AudioRing SPSC Buffer Cursor Desync
## Problem
A critical bug causes 10-16 seconds of bidirectional audio silence mid-call (~25-30s in). Both participants go silent at the exact same moment. The QUIC transport, relay, Opus codec, and FEC are all healthy — the bug is in the lock-free ring buffer that transfers decoded PCM from the Rust recv task to the Kotlin AudioTrack playout thread.
**Root cause:** `AudioRing::write()` modifies `read_pos` from the producer thread during overflow handling (lines 68-72 of `audio_ring.rs`). This violates the SPSC invariant — only the consumer should own `read_pos`. When both threads write to `read_pos`, a race corrupts the cursor state, causing the reader to see an empty or stale buffer for 12-16 seconds.
**Full forensics:** `debug/INCIDENT-2026-04-06-playout-ring-desync.md`
---
## Solution: Reader-Detects-Lap Architecture
The writer NEVER touches `read_pos`. On overflow, the writer simply overwrites old buffer data and advances `write_pos`. The reader detects it was lapped and self-corrects by snapping its own `read_pos` forward.
---
## Implementation Steps
### Step 1: Rewrite `AudioRing`
**File:** `crates/wzp-android/src/audio_ring.rs`
Replace the entire implementation with:
**Constants:**
```rust
/// Ring buffer capacity — must be a power of 2 for bitmask indexing.
/// 16384 samples = 341.3ms at 48kHz mono. Provides 70% more headroom
/// than the previous 9600 (200ms) for surviving Android GC pauses.
const RING_CAPACITY: usize = 16384; // 2^14
const RING_MASK: usize = RING_CAPACITY - 1;
```
**Struct:**
```rust
pub struct AudioRing {
buf: Box<[i16; RING_CAPACITY]>,
write_pos: AtomicUsize, // monotonically increasing, ONLY written by producer
read_pos: AtomicUsize, // monotonically increasing, ONLY written by consumer
overflow_count: AtomicU64, // incremented by reader when it detects a lap
underrun_count: AtomicU64, // incremented by reader when ring is empty
}
```
**`write()` — producer. Does NOT touch `read_pos`:**
```rust
pub fn write(&self, samples: &[i16]) -> usize {
let count = samples.len().min(RING_CAPACITY);
let w = self.write_pos.load(Ordering::Relaxed);
for i in 0..count {
unsafe {
let ptr = self.buf.as_ptr() as *mut i16;
*ptr.add((w + i) & RING_MASK) = samples[i];
}
}
self.write_pos.store(w.wrapping_add(count), Ordering::Release);
count
}
```
**`read()` — consumer. Detects lap, self-corrects:**
```rust
pub fn read(&self, out: &mut [i16]) -> usize {
let w = self.write_pos.load(Ordering::Acquire);
let mut r = self.read_pos.load(Ordering::Relaxed);
let mut avail = w.wrapping_sub(r);
// Lap detection: writer has overwritten our unread data.
// Snap read_pos forward to oldest valid data in the buffer.
// Safe because we (the reader) are the sole owner of read_pos.
if avail > RING_CAPACITY {
r = w.wrapping_sub(RING_CAPACITY);
avail = RING_CAPACITY;
self.overflow_count.fetch_add(1, Ordering::Relaxed);
}
let count = out.len().min(avail);
if count == 0 {
if w == r {
self.underrun_count.fetch_add(1, Ordering::Relaxed);
}
return 0;
}
for i in 0..count {
out[i] = unsafe { *self.buf.as_ptr().add((r + i) & RING_MASK) };
}
self.read_pos.store(r.wrapping_add(count), Ordering::Release);
count
}
```
**`available()` — clamped for external callers:**
```rust
pub fn available(&self) -> usize {
let w = self.write_pos.load(Ordering::Acquire);
let r = self.read_pos.load(Ordering::Relaxed);
w.wrapping_sub(r).min(RING_CAPACITY)
}
```
**`free_space()` — keep for API compat:**
```rust
pub fn free_space(&self) -> usize {
RING_CAPACITY.saturating_sub(self.available())
}
```
**Diagnostic accessors:**
```rust
pub fn overflow_count(&self) -> u64 {
self.overflow_count.load(Ordering::Relaxed)
}
pub fn underrun_count(&self) -> u64 {
self.underrun_count.load(Ordering::Relaxed)
}
```
**Constructor:**
```rust
pub fn new() -> Self {
debug_assert!(RING_CAPACITY.is_power_of_two());
Self {
buf: Box::new([0i16; RING_CAPACITY]),
write_pos: AtomicUsize::new(0),
read_pos: AtomicUsize::new(0),
overflow_count: AtomicU64::new(0),
underrun_count: AtomicU64::new(0),
}
}
```
**Imports to add:** `use std::sync::atomic::AtomicU64;`
**Safety comment update:**
```rust
// SAFETY: AudioRing is SPSC — one thread writes (producer), one reads (consumer).
// The producer only writes write_pos. The consumer only writes read_pos.
// Neither thread writes the other's cursor. Buffer indices are derived from
// the owning thread's cursor, ensuring no concurrent access to the same index.
```
---
### Step 2: Add counter fields to `CallStats`
**File:** `crates/wzp-android/src/stats.rs`
Add three fields to the `CallStats` struct (after `fec_recovered`):
```rust
/// Playout ring overflow count (reader was lapped by writer).
pub playout_overflows: u64,
/// Playout ring underrun count (reader found empty buffer).
pub playout_underruns: u64,
/// Capture ring overflow count.
pub capture_overflows: u64,
```
These derive `Default` (= 0) automatically via the existing `#[derive(Default)]`.
---
### Step 3: Wire ring diagnostics into engine stats + logging
**File:** `crates/wzp-android/src/engine.rs`
**3a.** In `get_stats()` (~line 181), populate the new fields:
```rust
stats.playout_overflows = self.state.playout_ring.overflow_count();
stats.playout_underruns = self.state.playout_ring.underrun_count();
stats.capture_overflows = self.state.capture_ring.overflow_count();
```
**3b.** In the recv task periodic stats log, add ring health:
```rust
info!(
frames_decoded,
fec_recovered,
recv_errors,
max_recv_gap_ms,
playout_avail = state.playout_ring.available(),
playout_overflows = state.playout_ring.overflow_count(),
playout_underruns = state.playout_ring.underrun_count(),
"recv stats"
);
```
**3c.** In the send task periodic stats log, add capture ring health:
```rust
info!(
seq = s,
block_id,
frames_sent,
frames_dropped,
send_errors,
ring_avail = state.capture_ring.available(),
capture_overflows = state.capture_ring.overflow_count(),
"send stats"
);
```
---
### Step 4: Parse new stats in Kotlin
**File:** `android/app/src/main/java/com/wzp/engine/CallStats.kt`
Add fields to the data class:
```kotlin
val playoutOverflows: Long = 0,
val playoutUnderruns: Long = 0,
val captureOverflows: Long = 0,
```
Add parsing in `fromJson()`:
```kotlin
playoutOverflows = obj.optLong("playout_overflows", 0),
playoutUnderruns = obj.optLong("playout_underruns", 0),
captureOverflows = obj.optLong("capture_overflows", 0),
```
No UI changes needed — these fields will appear in debug report JSON automatically.
---
### Step 5: Unit tests
**File:** `crates/wzp-android/src/audio_ring.rs` — add `#[cfg(test)] mod tests`
```rust
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn capacity_is_power_of_two() {
assert!(RING_CAPACITY.is_power_of_two());
}
#[test]
fn basic_write_read() {
let ring = AudioRing::new();
let input: Vec<i16> = (0..960).map(|i| i as i16).collect();
ring.write(&input);
assert_eq!(ring.available(), 960);
let mut output = vec![0i16; 960];
let read = ring.read(&mut output);
assert_eq!(read, 960);
assert_eq!(output, input);
assert_eq!(ring.available(), 0);
}
#[test]
fn wraparound() {
let ring = AudioRing::new();
let frame = vec![42i16; 960];
// Write enough to wrap the buffer multiple times
for _ in 0..20 {
ring.write(&frame);
let mut out = vec![0i16; 960];
ring.read(&mut out);
assert!(out.iter().all(|&s| s == 42));
}
}
#[test]
fn overflow_detected_by_reader() {
let ring = AudioRing::new();
// Write more than RING_CAPACITY without reading
let big = vec![7i16; RING_CAPACITY + 960];
ring.write(&big[..RING_CAPACITY]);
ring.write(&big[RING_CAPACITY..]);
// Reader should detect lap
let mut out = vec![0i16; 960];
let read = ring.read(&mut out);
assert!(read > 0);
assert_eq!(ring.overflow_count(), 1);
// Data should be from the most recent writes
assert!(out.iter().all(|&s| s == 7));
}
#[test]
fn writer_never_modifies_read_pos() {
let ring = AudioRing::new();
// Read pos should stay at 0 until read() is called
let data = vec![1i16; RING_CAPACITY + 960];
ring.write(&data);
// read_pos is private, but we can check available() > CAPACITY
// which proves write() didn't advance read_pos
let w = ring.write_pos.load(std::sync::atomic::Ordering::Relaxed);
let r = ring.read_pos.load(std::sync::atomic::Ordering::Relaxed);
assert_eq!(r, 0, "write() must not modify read_pos");
assert!(w.wrapping_sub(r) > RING_CAPACITY);
}
#[test]
fn underrun_counted() {
let ring = AudioRing::new();
let mut out = vec![0i16; 960];
let read = ring.read(&mut out);
assert_eq!(read, 0);
assert_eq!(ring.underrun_count(), 1);
}
#[test]
fn overflow_recovery_reads_recent_data() {
let ring = AudioRing::new();
// Fill with old data
let old = vec![1i16; RING_CAPACITY];
ring.write(&old);
// Overwrite with new data (lapping the reader)
let new_data = vec![99i16; 960];
ring.write(&new_data);
// Reader should snap forward and get recent data
let mut out = vec![0i16; RING_CAPACITY];
let read = ring.read(&mut out);
assert_eq!(read, RING_CAPACITY);
// The last 960 samples should be 99
assert!(out[RING_CAPACITY - 960..].iter().all(|&s| s == 99));
assert_eq!(ring.overflow_count(), 1);
}
}
```
---
## Memory Ordering Reference
| Operation | Ordering | Rationale |
|-----------|----------|-----------|
| `write_pos.store` in `write()` | Release | Buffer writes visible before cursor advances |
| `write_pos.load` in `read()` | Acquire | Pairs with Release above — sees all buffer writes |
| `write_pos.load` in `write()` | Relaxed | Writer is sole owner of write_pos |
| `read_pos.load` in `read()` | Relaxed | Reader is sole owner of read_pos |
| `read_pos.store` in `read()` | Release | Makes available() consistent from any thread |
| `read_pos.load` in `available()` | Relaxed | Informational only, slight staleness OK |
| All counters | Relaxed | Diagnostic only |
---
## Capacity Tradeoff
| Capacity | Duration | Memory | Verdict |
|----------|----------|--------|---------|
| 8192 (2^13) | 170ms | 16KB | Less than current 200ms — risky |
| **16384 (2^14)** | **341ms** | **32KB** | **70% more headroom, bitmask indexing** |
| 32768 (2^15) | 682ms | 64KB | Excessive latency on overflow recovery |
---
## Verification
1. `cargo test -p wzp-android` — new unit tests pass
2. `cargo ndk -t arm64-v8a build --release -p wzp-android` — ARM cross-compile succeeds
3. Build APK, install on both test devices (Nothing A059 + Pixel 6)
4. 2+ minute call — verify no audio gaps
5. Check debug report JSON: `playout_overflows` should be 0 or very small
6. Check logcat `wzp_android` tag: send/recv stats show healthy ring state
7. Stress test: play music through one device speaker while on call — forces high ring throughput
---
## Files to Modify
| File | What changes |
|------|-------------|
| `crates/wzp-android/src/audio_ring.rs` | Complete rewrite — the core fix |
| `crates/wzp-android/src/stats.rs` | Add 3 counter fields |
| `crates/wzp-android/src/engine.rs` | Wire counters into get_stats() + periodic logs |
| `android/app/src/main/java/com/wzp/engine/CallStats.kt` | Parse 3 new JSON fields |
## What Does NOT Change
- `AudioPipeline.kt` — calls `readAudio()`/`writeAudio()` unchanged; ring fix is transparent
- `jni_bridge.rs` — JNI bridge passes through unchanged
- `audio_android.rs` — separate Oboe-based ring, currently unused, different design
- Relay code — relay is confirmed healthy
- Desktop client — uses `Mutex + mpsc`, not `AudioRing`

View File

@@ -0,0 +1,149 @@
# Fix: Capture/Playout Thread Use-After-Free on Hangup
## Problem
App crashes (SIGSEGV) when hanging up a call. The capture thread (`wzp-capture`) calls `engine.writeAudio()` via JNI after `teardown()` has freed the native engine handle. Same race exists for the playout thread's `readAudio()`.
**Root cause:** TOCTOU race between the `nativeHandle == 0L` check in `WzpEngine.writeAudio()`/`readAudio()` and `destroy()` freeing the native memory on the ViewModel thread. Audio threads can't be joined (libcrypto TLS destructor crash), so there's no synchronization between `stopAudio()` and `destroy()`.
**Full forensics:** `debug/INCIDENT-2026-04-06-capture-thread-use-after-free.md`
---
## Solution: Destroy Latch
Add a `CountDownLatch(2)` that both audio threads count down after exiting their loops. `teardown()` awaits the latch (with timeout) before calling `destroy()`, guaranteeing no in-flight JNI calls.
---
## Implementation Steps
### Step 1: Add a drain latch to `AudioPipeline`
**File:** `android/app/src/main/java/com/wzp/audio/AudioPipeline.kt`
Add a `CountDownLatch` field:
```kotlin
import java.util.concurrent.CountDownLatch
import java.util.concurrent.TimeUnit
class AudioPipeline(private val context: Context) {
// ... existing fields ...
/** Latch counted down by each audio thread after exiting its loop.
* stop() does NOT wait on this — teardown waits via awaitDrain(). */
private var drainLatch: CountDownLatch? = null
```
In `start()`, create the latch before spawning threads:
```kotlin
fun start(engine: WzpEngine) {
if (running) return
running = true
drainLatch = CountDownLatch(2) // one for capture, one for playout
captureThread = Thread({
runCapture(engine)
drainLatch?.countDown() // signal: capture loop exited
parkThread()
}, "wzp-capture").apply { ... }
playoutThread = Thread({
runPlayout(engine)
drainLatch?.countDown() // signal: playout loop exited
parkThread()
}, "wzp-playout").apply { ... }
// ...
}
```
Add `awaitDrain()` — called by ViewModel before `destroy()`:
```kotlin
/** Block until both audio threads have exited their loops (max 200ms).
* After this returns, no more JNI calls to the engine will be made. */
fun awaitDrain(): Boolean {
return drainLatch?.await(200, TimeUnit.MILLISECONDS) ?: true
}
```
`stop()` remains unchanged (non-blocking, sets `running = false`).
### Step 2: Update `CallViewModel.teardown()` to await drain
**File:** `android/app/src/main/java/com/wzp/ui/call/CallViewModel.kt`
Change teardown to wait for audio threads before destroying:
```kotlin
private fun teardown(stopService: Boolean = true) {
Log.i(TAG, "teardown: stopping audio, stopService=$stopService")
val hadCall = audioStarted
CallService.onStopFromNotification = null
stopAudio() // sets running=false (non-blocking)
stopStatsPolling()
// Wait for audio threads to exit their loops before destroying the engine.
// This guarantees no in-flight JNI calls to writeAudio/readAudio.
val drained = audioPipeline?.awaitDrain() ?: true
if (!drained) {
Log.w(TAG, "teardown: audio threads did not drain in time")
}
audioPipeline = null
Log.i(TAG, "teardown: stopping engine")
try { engine?.stopCall() } catch (e: Exception) { Log.w(TAG, "stopCall err: $e") }
try { engine?.destroy() } catch (e: Exception) { Log.w(TAG, "destroy err: $e") }
engine = null
engineInitialized = false
// ... rest unchanged
}
```
**Key change:** `awaitDrain()` is called AFTER `stopAudio()` (which sets `running=false`) but BEFORE `engine?.destroy()`. The latch guarantees both threads have exited their `while(running)` loops and will never call `writeAudio`/`readAudio` again.
Also move `audioPipeline = null` to after `awaitDrain()` to keep the reference alive for the latch call.
### Step 3: Move `stopAudio()` pipeline nulling
**File:** `android/app/src/main/java/com/wzp/ui/call/CallViewModel.kt`
In `stopAudio()`, do NOT null out the pipeline — let `teardown()` handle it after drain:
```kotlin
private fun stopAudio() {
if (!audioStarted) return
audioPipeline?.stop() // sets running=false
// DON'T null audioPipeline here — teardown() needs it for awaitDrain()
audioRouteManager?.unregister()
audioRouteManager?.setSpeaker(false)
_isSpeaker.value = false
audioStarted = false
}
```
---
## Files to Modify
| File | What changes |
|------|-------------|
| `android/.../audio/AudioPipeline.kt` | Add `CountDownLatch`, `countDown()` in threads, `awaitDrain()` method |
| `android/.../ui/call/CallViewModel.kt` | `teardown()` calls `awaitDrain()` before `destroy()`; `stopAudio()` doesn't null pipeline |
## What Does NOT Change
- `WzpEngine.kt` — the `nativeHandle == 0L` guard stays as defense-in-depth
- `jni_bridge.rs``panic::catch_unwind` stays as last resort
- `AudioPipeline.stop()` — remains non-blocking
- Thread parking — still needed to avoid libcrypto TLS crash
## Verification
1. Build APK, install on test device
2. Make a call, hang up — verify no crash in logcat (`adb logcat -s AndroidRuntime:E DEBUG:F`)
3. Rapid call/hangup/call/hangup cycles — stress the teardown path
4. Check logcat for `teardown: audio threads did not drain in time` — should never appear under normal conditions
5. Verify debug report still works after hangup (latch doesn't interfere with report collection)

376
scripts/build-android-cloud.sh Executable file
View File

@@ -0,0 +1,376 @@
#!/usr/bin/env bash
set -euo pipefail
# Build WarzonePhone Android APK using a temporary Hetzner Cloud VPS.
# Creates a VM, builds both debug and release APKs, downloads them, destroys the VM.
#
# Prerequisites: hcloud CLI authenticated, SSH key "wz" registered.
#
# Usage:
# ./scripts/build-android-cloud.sh Full build (create → build → download → destroy)
# ./scripts/build-android-cloud.sh --prepare Create VM and install deps only
# ./scripts/build-android-cloud.sh --build Build on existing VM
# ./scripts/build-android-cloud.sh --transfer Download APKs from VM
# ./scripts/build-android-cloud.sh --destroy Delete the VM
# ./scripts/build-android-cloud.sh --all prepare + build + transfer (VM persists)
# ./scripts/build-android-cloud.sh --upload Re-upload source to existing VM
#
# Environment variables (all optional):
# WZP_BRANCH Branch to build (default: feat/android-voip-client)
# WZP_SERVER_TYPE Hetzner server type (default: cx32 — 4 vCPU, 8GB RAM)
# WZP_KEEP_VM Set to 1 to skip destroy on full build
SSH_KEY_NAME="wz"
SSH_KEY_PATH="/Users/manwe/CascadeProjects/wzp"
SERVER_TYPE="${WZP_SERVER_TYPE:-cx33}"
IMAGE="ubuntu-24.04"
SERVER_NAME="wzp-android-builder"
REMOTE_USER="root"
OUTPUT_DIR="target/android-apk"
PROJECT_DIR="$(cd "$(dirname "$0")/.." && pwd)"
BRANCH="${WZP_BRANCH:-feat/android-voip-client}"
KEEP_VM="${WZP_KEEP_VM:-0}"
SSH_OPTS="-o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null -o ConnectTimeout=10 -o LogLevel=ERROR"
# NDK 26.1 — NDK 27 crashes scudo on Android 16 MTE devices
NDK_VERSION="26.1.10909125"
ANDROID_API="34"
# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------
log() { echo -e "\n\033[1;36m>>> $*\033[0m"; }
err() { echo -e "\033[1;31mERROR: $*\033[0m" >&2; }
die() { err "$@"; do_destroy_quiet; exit 1; }
get_vm_ip() {
hcloud server list -o columns=name,ipv4 -o noheader 2>/dev/null | grep "$SERVER_NAME" | awk '{print $2}' | tr -d ' '
}
ssh_cmd() {
local ip
ip=$(get_vm_ip)
[ -n "$ip" ] || die "No VM found. Run --prepare first."
ssh $SSH_OPTS -A -i "$SSH_KEY_PATH" "$REMOTE_USER@$ip" "$@"
}
scp_down() {
local ip
ip=$(get_vm_ip)
[ -n "$ip" ] || die "No VM found."
scp $SSH_OPTS -i "$SSH_KEY_PATH" "$REMOTE_USER@$ip:$1" "$2"
}
do_destroy_quiet() {
local name
name=$(hcloud server list -o columns=name -o noheader 2>/dev/null | grep "$SERVER_NAME" | tr -d ' ' || true)
if [ -n "$name" ]; then
echo ""
err "Cleaning up — destroying VM $name"
hcloud server delete "$name" 2>/dev/null || true
fi
}
# ---------------------------------------------------------------------------
# --prepare: Create VM, install all build dependencies
# ---------------------------------------------------------------------------
do_prepare() {
# Check if VM already exists
local existing
existing=$(hcloud server list -o columns=name -o noheader 2>/dev/null | grep "$SERVER_NAME" | tr -d ' ' || true)
if [ -n "$existing" ]; then
log "VM already exists: $existing — reusing"
do_upload
return
fi
log "Creating Hetzner VM ($SERVER_TYPE, $IMAGE)..."
hcloud server create \
--name "$SERVER_NAME" \
--type "$SERVER_TYPE" \
--image "$IMAGE" \
--ssh-key "$SSH_KEY_NAME" \
--location fsn1 \
--quiet \
|| die "Failed to create VM"
local ip
ip=$(get_vm_ip)
[ -n "$ip" ] || die "VM created but no IP found"
echo " VM: $SERVER_NAME @ $ip"
# Wait for SSH
log "Waiting for SSH..."
local ok=0
for i in $(seq 1 30); do
if ssh $SSH_OPTS -i "$SSH_KEY_PATH" "$REMOTE_USER@$ip" "echo ok" &>/dev/null; then
ok=1
break
fi
sleep 2
done
[ "$ok" -eq 1 ] || die "SSH timeout after 60s"
# System packages
log "Installing system packages (cmake, JDK 17, build tools)..."
ssh_cmd "export DEBIAN_FRONTEND=noninteractive && \
apt-get update -qq && \
apt-get install -y -qq \
build-essential cmake curl git libssl-dev pkg-config \
unzip wget zip openjdk-17-jdk-headless \
> /dev/null 2>&1" \
|| die "Failed to install system packages"
# Verify cmake version (must be <= 3.30)
local cmake_ver
cmake_ver=$(ssh_cmd "cmake --version | head -1")
echo " cmake: $cmake_ver"
echo " java: $(ssh_cmd "java -version 2>&1 | head -1")"
# Rust
log "Installing Rust toolchain..."
ssh_cmd "curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh -s -- -y --default-toolchain stable > /dev/null 2>&1" \
|| die "Failed to install Rust"
ssh_cmd "source \$HOME/.cargo/env && rustup target add aarch64-linux-android > /dev/null 2>&1"
ssh_cmd "source \$HOME/.cargo/env && cargo install cargo-ndk > /dev/null 2>&1" \
|| die "Failed to install cargo-ndk"
echo " rust: $(ssh_cmd "source \$HOME/.cargo/env && rustc --version")"
# Android SDK + NDK
log "Installing Android SDK + NDK $NDK_VERSION..."
ssh_cmd "export JAVA_HOME=/usr/lib/jvm/java-17-openjdk-amd64 && \
mkdir -p \$HOME/android-sdk/cmdline-tools && \
cd /tmp && \
wget -q https://dl.google.com/android/repository/commandlinetools-linux-11076708_latest.zip -O cmdtools.zip && \
unzip -qo cmdtools.zip -d \$HOME/android-sdk/cmdline-tools && \
mv \$HOME/android-sdk/cmdline-tools/cmdline-tools \$HOME/android-sdk/cmdline-tools/latest 2>/dev/null; \
yes | \$HOME/android-sdk/cmdline-tools/latest/bin/sdkmanager --licenses > /dev/null 2>&1; \
\$HOME/android-sdk/cmdline-tools/latest/bin/sdkmanager --install \
'platforms;android-${ANDROID_API}' \
'build-tools;${ANDROID_API}.0.0' \
'ndk;${NDK_VERSION}' \
'platform-tools' \
2>&1 | grep -v '^\[' > /dev/null" \
|| die "Failed to install Android SDK/NDK"
ssh_cmd "[ -d \$HOME/android-sdk/ndk/$NDK_VERSION ]" \
|| die "NDK not found after install"
echo " NDK: $NDK_VERSION"
# Upload source
do_upload
log "VM ready!"
echo " IP: $ip"
echo " SSH: ssh -A -i $SSH_KEY_PATH root@$ip"
}
# ---------------------------------------------------------------------------
# --upload: Upload source code to VM
# ---------------------------------------------------------------------------
do_upload() {
log "Uploading source code (rsync)..."
local ip
ip=$(get_vm_ip)
[ -n "$ip" ] || die "No VM found."
rsync -az --delete \
--exclude='target' \
--exclude='.git' \
--exclude='.claude' \
--exclude='node_modules' \
--exclude='dist' \
--exclude='desktop/src-tauri/gen' \
-e "ssh $SSH_OPTS -i $SSH_KEY_PATH" \
"$PROJECT_DIR/" "$REMOTE_USER@$ip:/root/wzp-build/"
echo " Source uploaded."
}
# ---------------------------------------------------------------------------
# --build: Build native .so + debug & release APKs
# ---------------------------------------------------------------------------
do_build() {
log "Building Rust native library (arm64-v8a, release)..."
# Clean Rust release target to force full rebuild.
# cargo-ndk only copies libc++_shared.so when it actually links — a partial
# clean that skips relinking leaves libc++_shared.so missing from jniLibs.
ssh_cmd "rm -rf /root/wzp-build/target/aarch64-linux-android/release \
/root/wzp-build/android/app/src/main/jniLibs/arm64-v8a"
# ANDROID_NDK must be set (not just ANDROID_NDK_HOME) — cmake checks it
ssh_cmd "source \$HOME/.cargo/env && \
export ANDROID_HOME=\$HOME/android-sdk && \
export ANDROID_NDK_HOME=\$ANDROID_HOME/ndk/$NDK_VERSION && \
export ANDROID_NDK=\$ANDROID_NDK_HOME && \
cd /root/wzp-build && \
cargo ndk -t arm64-v8a \
-o android/app/src/main/jniLibs \
build --release -p wzp-android 2>&1" | tail -5 \
|| die "Rust native build failed"
ssh_cmd "[ -f /root/wzp-build/android/app/src/main/jniLibs/arm64-v8a/libwzp_android.so ]" \
|| die "libwzp_android.so not found after build"
local so_size
so_size=$(ssh_cmd "du -h /root/wzp-build/android/app/src/main/jniLibs/arm64-v8a/libwzp_android.so | cut -f1")
echo " .so: $so_size"
# Generate debug keystore if missing
ssh_cmd "[ -f /root/wzp-build/android/keystore/wzp-debug.jks ] || \
(mkdir -p /root/wzp-build/android/keystore && \
keytool -genkey -v \
-keystore /root/wzp-build/android/keystore/wzp-debug.jks \
-keyalg RSA -keysize 2048 -validity 10000 \
-alias wzp-debug -storepass android -keypass android \
-dname 'CN=WZP Debug' > /dev/null 2>&1)"
# Build debug APK
log "Building debug APK..."
ssh_cmd "export JAVA_HOME=/usr/lib/jvm/java-17-openjdk-amd64 && \
export ANDROID_HOME=\$HOME/android-sdk && \
cd /root/wzp-build/android && \
chmod +x ./gradlew && \
./gradlew assembleDebug --no-daemon --warning-mode=none 2>&1" | tail -3 \
|| die "Debug APK build failed"
# Build release APK (uses debug keystore for now)
log "Building release APK..."
# Copy debug keystore as release keystore (same password in build.gradle)
ssh_cmd "cp /root/wzp-build/android/keystore/wzp-debug.jks /root/wzp-build/android/keystore/wzp-release.jks 2>/dev/null; true"
ssh_cmd "export JAVA_HOME=/usr/lib/jvm/java-17-openjdk-amd64 && \
export ANDROID_HOME=\$HOME/android-sdk && \
cd /root/wzp-build/android && \
./gradlew assembleRelease --no-daemon --warning-mode=none 2>&1" | tail -3 \
|| echo " (release APK failed — debug APK still available)"
log "Build complete!"
ssh_cmd "find /root/wzp-build/android -name '*.apk' -path '*/outputs/apk/*' -exec ls -lh {} \;"
}
# ---------------------------------------------------------------------------
# --transfer: Download APKs to local machine
# ---------------------------------------------------------------------------
do_transfer() {
log "Downloading APKs..."
mkdir -p "$OUTPUT_DIR"
local ip
ip=$(get_vm_ip)
# Debug APK
local debug_apk
debug_apk=$(ssh_cmd "find /root/wzp-build/android -name 'app-debug*.apk' -path '*/outputs/apk/*' | head -1")
if [ -n "$debug_apk" ]; then
scp_down "$debug_apk" "$OUTPUT_DIR/wzp-debug.apk"
echo " debug: $OUTPUT_DIR/wzp-debug.apk ($(du -h "$OUTPUT_DIR/wzp-debug.apk" | cut -f1))"
fi
# Release APK
local release_apk
release_apk=$(ssh_cmd "find /root/wzp-build/android -name 'app-release*.apk' -path '*/outputs/apk/*' | head -1" || true)
if [ -n "$release_apk" ]; then
scp_down "$release_apk" "$OUTPUT_DIR/wzp-release.apk"
echo " release: $OUTPUT_DIR/wzp-release.apk ($(du -h "$OUTPUT_DIR/wzp-release.apk" | cut -f1))"
fi
# Also copy the .so for inspection
scp_down "/root/wzp-build/android/app/src/main/jniLibs/arm64-v8a/libwzp_android.so" "$OUTPUT_DIR/libwzp_android.so"
echo " .so: $OUTPUT_DIR/libwzp_android.so"
log "Transfer complete!"
echo ""
echo " Install debug: adb install -r $OUTPUT_DIR/wzp-debug.apk"
[ -f "$OUTPUT_DIR/wzp-release.apk" ] && echo " Install release: adb install -r $OUTPUT_DIR/wzp-release.apk"
}
# ---------------------------------------------------------------------------
# --destroy: Delete the VM
# ---------------------------------------------------------------------------
do_destroy() {
local name
name=$(hcloud server list -o columns=name -o noheader 2>/dev/null | grep "$SERVER_NAME" | tr -d ' ' || true)
if [ -z "$name" ]; then
echo "No VM to destroy."
return
fi
log "Deleting VM: $name"
hcloud server delete "$name"
echo " Done."
}
# ---------------------------------------------------------------------------
# Full build: create → build → transfer → destroy
# ---------------------------------------------------------------------------
do_full() {
trap 'err "Build failed!"; do_destroy_quiet; exit 1' ERR
do_prepare
# Disable trap during build — release APK failure is non-fatal
trap - ERR
do_build
do_transfer
trap 'err "Build failed!"; do_destroy_quiet; exit 1' ERR
if [ "$KEEP_VM" = "1" ]; then
log "VM kept alive (WZP_KEEP_VM=1). Destroy with: $0 --destroy"
else
do_destroy
fi
log "All done!"
echo ""
echo " ┌──────────────────────────────────────────────────┐"
echo " │ Debug APK: $OUTPUT_DIR/wzp-debug.apk"
[ -f "$OUTPUT_DIR/wzp-release.apk" ] && \
echo " │ Release APK: $OUTPUT_DIR/wzp-release.apk"
echo " │"
echo " │ Install: adb install -r $OUTPUT_DIR/wzp-debug.apk"
echo " └──────────────────────────────────────────────────┘"
}
# ---------------------------------------------------------------------------
# Main
# ---------------------------------------------------------------------------
case "${1:-}" in
--prepare) do_prepare ;;
--build) do_build ;;
--transfer) do_transfer ;;
--destroy) do_destroy ;;
--upload) do_upload ;;
--all)
do_prepare
do_build
do_transfer
log "VM still running. Destroy with: $0 --destroy"
;;
"")
do_full
;;
*)
echo "Usage: $0 [--prepare|--build|--transfer|--destroy|--all|--upload]"
echo ""
echo " (no args) Full build: create VM → build → download → destroy VM"
echo " --prepare Create VM and install deps"
echo " --build Build on existing VM"
echo " --transfer Download APKs from VM"
echo " --destroy Delete the VM"
echo " --all prepare + build + transfer (VM persists)"
echo " --upload Re-upload source to existing VM"
echo ""
echo "Environment:"
echo " WZP_BRANCH=$BRANCH"
echo " WZP_SERVER_TYPE=$SERVER_TYPE"
echo " WZP_KEEP_VM=$KEEP_VM (set to 1 to skip auto-destroy)"
exit 1
;;
esac

240
scripts/build-android.sh Executable file
View File

@@ -0,0 +1,240 @@
#!/usr/bin/env bash
# =============================================================================
# WZ Phone — Android APK build script for Debian 12 (Bookworm)
#
# Sets up a complete build environment from scratch and produces a debug APK.
# Idempotent — safe to run multiple times (skips already-installed components).
#
# Tested on: Debian 12 x86_64, cross-compiling to aarch64-linux-android
#
# Why these specific versions:
#
# cmake 3.25-3.28 (system package from apt)
# cmake 3.25 (Debian 12) and 3.28 (Ubuntu 24.04) both work.
# cmake 3.31+ has armv7/aarch64 flag conflicts in Android-Determine.cmake.
# cmake 4.x drops cmake_minimum_required < 3.5.
# Do NOT use pip cmake — it bundles its own modules with different bugs.
# CRITICAL: must set ANDROID_NDK=$ANDROID_NDK_HOME (cmake checks ANDROID_NDK).
#
# NDK 26.1.10909125 (r26b)
# NDK 27+ ships a newer libc++_shared.so with different scudo allocator
# defaults. On Android 16 devices with MTE (Memory Tagging Extension)
# enabled (e.g. Nothing A059), NDK 27's scudo crashes during malloc/calloc.
# NDK 26.1 is the last stable version for these devices.
# Matches build.gradle.kts: ndkVersion = "26.1.10909125"
#
# JDK 17 (openjdk-17-jdk-headless)
# Gradle 8.5 + AGP 8.2.0 officially support JDK 17.
# JDK 21 works for compilation but has Gradle daemon compat issues.
#
# Rust stable (currently 1.94.1)
# Edition 2024, MSRV 1.85. Stable channel is fine.
#
# ANDROID_NDK=$ANDROID_NDK_HOME (BOTH must be set)
# cmake's Android platform module checks ANDROID_NDK (no _HOME suffix).
# cargo-ndk sets ANDROID_NDK_HOME. Both must point to the same path.
#
# Usage:
# chmod +x scripts/build-android.sh
# ./scripts/build-android.sh # build from current tree
# WZP_CLONE=1 ./scripts/build-android.sh # clone fresh from git
# WZP_COMMIT=2092245 ./scripts/build-android.sh # pin to specific commit
#
# Environment variables (all optional):
# WZP_CLONE Set to 1 to clone from git instead of using current dir
# WZP_REPO Git clone URL (default: ssh://git@git.manko.yoga:222/manawenuz/wz-phone)
# WZP_BRANCH Branch to checkout (default: feat/android-voip-client)
# WZP_COMMIT Commit to pin to (default: HEAD)
# WZP_WORKDIR Build directory (default: /tmp/wzp-build)
# ANDROID_API SDK platform level (default: 34)
# NDK_VERSION NDK version string (default: 26.1.10909125)
# =============================================================================
set -euo pipefail
# ---------------------------------------------------------------------------
# Configuration
# ---------------------------------------------------------------------------
CLONE="${WZP_CLONE:-0}"
REPO="${WZP_REPO:-ssh://git@git.manko.yoga:222/manawenuz/wz-phone}"
BRANCH="${WZP_BRANCH:-feat/android-voip-client}"
COMMIT="${WZP_COMMIT:-}"
WORKDIR="${WZP_WORKDIR:-/tmp/wzp-build}"
ANDROID_API="${ANDROID_API:-34}"
NDK_VERSION="${NDK_VERSION:-26.1.10909125}"
ANDROID_HOME="${ANDROID_HOME:-$HOME/android-sdk}"
ANDROID_NDK_HOME="$ANDROID_HOME/ndk/$NDK_VERSION"
# cmake checks ANDROID_NDK (not _HOME) — both must be set
ANDROID_NDK="$ANDROID_NDK_HOME"
JAVA_HOME="/usr/lib/jvm/java-17-openjdk-$(dpkg --print-architecture)"
CMDLINE_TOOLS_URL="https://dl.google.com/android/repository/commandlinetools-linux-11076708_latest.zip"
export ANDROID_HOME ANDROID_NDK_HOME ANDROID_NDK JAVA_HOME
export PATH="$JAVA_HOME/bin:$ANDROID_HOME/cmdline-tools/latest/bin:$ANDROID_HOME/platform-tools:$HOME/.cargo/bin:$PATH"
log() { echo -e "\n\033[1;36m>>> $*\033[0m"; }
err() { echo -e "\033[1;31mERROR: $*\033[0m" >&2; exit 1; }
# ---------------------------------------------------------------------------
# Step 1: System packages (cmake 3.25, JDK 17, make, git, etc.)
# ---------------------------------------------------------------------------
log "Installing system packages"
export DEBIAN_FRONTEND=noninteractive
apt-get update -qq
apt-get install -y -qq \
build-essential \
cmake \
curl \
git \
libssl-dev \
pkg-config \
unzip \
wget \
zip \
openjdk-17-jdk-headless \
2>/dev/null
# Verify critical versions
log "Verifying build environment"
echo " cmake: $(cmake --version | head -1)"
echo " java: $(java -version 2>&1 | head -1)"
echo " make: $(make --version | head -1)"
CMAKE_MAJOR=$(cmake --version | head -1 | grep -oP '\d+' | head -1)
CMAKE_MINOR=$(cmake --version | head -1 | grep -oP '\d+' | sed -n '2p')
if [ "$CMAKE_MAJOR" -gt 3 ] || { [ "$CMAKE_MAJOR" -eq 3 ] && [ "$CMAKE_MINOR" -gt 30 ]; }; then
err "cmake $(cmake --version | head -1) is too new! Need cmake <= 3.28.x. cmake 3.31+ has Android cross-compilation bugs."
fi
# ---------------------------------------------------------------------------
# Step 2: Rust toolchain
# ---------------------------------------------------------------------------
log "Setting up Rust toolchain"
if ! command -v rustup &>/dev/null; then
curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh -s -- -y --default-toolchain stable
source "$HOME/.cargo/env"
fi
rustup default stable
rustup target add aarch64-linux-android
echo " rustc: $(rustc --version)"
echo " cargo: $(cargo --version)"
if ! command -v cargo-ndk &>/dev/null; then
log "Installing cargo-ndk"
cargo install cargo-ndk
fi
echo " ndk: $(cargo ndk --version)"
# ---------------------------------------------------------------------------
# Step 3: Android SDK + NDK 26.1
# ---------------------------------------------------------------------------
log "Setting up Android SDK + NDK $NDK_VERSION"
if [ ! -f "$ANDROID_HOME/cmdline-tools/latest/bin/sdkmanager" ]; then
log "Downloading Android command-line tools"
mkdir -p "$ANDROID_HOME/cmdline-tools"
TMPZIP=$(mktemp /tmp/cmdline-tools-XXXXX.zip)
wget -q -O "$TMPZIP" "$CMDLINE_TOOLS_URL"
unzip -qo "$TMPZIP" -d "$ANDROID_HOME/cmdline-tools"
mv "$ANDROID_HOME/cmdline-tools/cmdline-tools" "$ANDROID_HOME/cmdline-tools/latest" 2>/dev/null || true
rm -f "$TMPZIP"
fi
yes | sdkmanager --licenses >/dev/null 2>&1 || true
if [ ! -d "$ANDROID_NDK_HOME" ]; then
log "Installing NDK $NDK_VERSION (this takes a few minutes)"
sdkmanager --install \
"platforms;android-${ANDROID_API}" \
"build-tools;${ANDROID_API}.0.0" \
"ndk;${NDK_VERSION}" \
"platform-tools" \
2>&1 | grep -v "^\[" || true
fi
[ -d "$ANDROID_NDK_HOME" ] || err "NDK not found at $ANDROID_NDK_HOME"
echo " NDK: $ANDROID_NDK_HOME"
echo " SDK: $ANDROID_HOME"
# ---------------------------------------------------------------------------
# Step 4: Source code
# ---------------------------------------------------------------------------
if [ "$CLONE" = "1" ]; then
log "Cloning $REPO (branch: $BRANCH)"
if [ -d "$WORKDIR/.git" ]; then
cd "$WORKDIR"
git fetch origin
else
rm -rf "$WORKDIR"
git clone --branch "$BRANCH" --recurse-submodules "$REPO" "$WORKDIR"
cd "$WORKDIR"
fi
git checkout "$BRANCH"
git pull origin "$BRANCH" || true
git submodule update --init --recursive
if [ -n "$COMMIT" ]; then
log "Pinning to commit $COMMIT"
git checkout "$COMMIT"
fi
else
# Use current directory (assume we're in the repo root)
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
WORKDIR="$(cd "$SCRIPT_DIR/.." && pwd)"
cd "$WORKDIR"
[ -f "Cargo.toml" ] || err "Not in repo root. Run from repo root or set WZP_CLONE=1"
fi
echo " HEAD: $(git log --oneline -1)"
# ---------------------------------------------------------------------------
# Step 5: Build native Rust library (.so)
# ---------------------------------------------------------------------------
log "Building Rust native library (arm64-v8a, release)"
cargo ndk -t arm64-v8a \
-o "$WORKDIR/android/app/src/main/jniLibs" \
build --release -p wzp-android
SO="$WORKDIR/android/app/src/main/jniLibs/arm64-v8a/libwzp_android.so"
[ -f "$SO" ] || err ".so not found at $SO"
echo " Built: $SO ($(du -h "$SO" | cut -f1))"
# ---------------------------------------------------------------------------
# Step 6: Generate debug keystore (if missing)
# ---------------------------------------------------------------------------
KEYSTORE="$WORKDIR/android/keystore/wzp-debug.jks"
if [ ! -f "$KEYSTORE" ]; then
log "Generating debug keystore"
mkdir -p "$(dirname "$KEYSTORE")"
keytool -genkey -v \
-keystore "$KEYSTORE" \
-keyalg RSA -keysize 2048 -validity 10000 \
-alias wzp-debug \
-storepass android -keypass android \
-dname "CN=WZP Debug" 2>&1 | tail -1
fi
# ---------------------------------------------------------------------------
# Step 7: Build Android APK
# ---------------------------------------------------------------------------
log "Building APK (debug)"
cd "$WORKDIR/android"
chmod +x ./gradlew
./gradlew assembleDebug --no-daemon --warning-mode=none
APK=$(find . -name "app-debug*.apk" -path "*/outputs/apk/*" | head -1)
[ -n "$APK" ] || err "APK not found"
APK_ABS="$(cd "$(dirname "$APK")" && pwd)/$(basename "$APK")"
# ---------------------------------------------------------------------------
# Done
# ---------------------------------------------------------------------------
log "Build complete!"
echo ""
echo " ┌──────────────────────────────────────────────────────────┐"
echo " │ APK: $APK_ABS"
echo " │ Size: $(du -h "$APK_ABS" | cut -f1)"
echo " │ SHA256: $(sha256sum "$APK_ABS" | cut -d' ' -f1)"
echo " └──────────────────────────────────────────────────────────┘"
echo ""
echo " Install: adb install -r $APK_ABS"
echo ""