Commit Graph

54 Commits

Author SHA1 Message Date
Siavash Sameni
4f2ad65418 fix(android_audio): add explicit pointer types for .cast() — was rejected by rustc E0282 on android target
Some checks failed
Mirror to GitHub / mirror (push) Failing after 37s
Build Release Binaries / build-amd64 (push) Failing after 4m6s
2026-04-09 22:02:48 +04:00
Siavash Sameni
0178cbd91d android(audio): Speaker button toggles earpiece↔speaker via JNI (WIP, untested)
Some checks failed
Mirror to GitHub / mirror (push) Failing after 39s
Build Release Binaries / build-amd64 (push) Has been cancelled
Build 9e37201 confirmed on-device that Usage::VoiceCommunication +
MODE_IN_COMMUNICATION + speakerphoneOn=false routes Oboe playout to the
handset earpiece and the callback drains the ring correctly. Next step:
let the user flip speakerphoneOn at runtime so the existing Speaker
button actually switches audio routing instead of just gating writes.

- Cargo.toml (android target): pull in `jni = 0.21` and
  `ndk-context = 0.1`. Both are already transitively in the lockfile
  via Tauri/Wry, so this just promotes them to direct deps.
- desktop/src-tauri/src/android_audio.rs: new module. Grabs the JavaVM +
  current Activity from `ndk_context::android_context()`, attaches a
  JNI thread, calls `activity.getSystemService("audio")` to get the
  AudioManager, and exposes `set_speakerphone(bool)` +
  `is_speakerphone_on()` helpers that call the AudioManager method of
  the same name. All gated behind `#[cfg(target_os = "android")]`.
- lib.rs: adds `mod android_audio;` (android only), two new Tauri
  commands `set_speakerphone(on)` and `is_speakerphone_on()` — desktop
  gets no-op stubs so the same frontend invoke() works everywhere.
  Both registered in the invoke_handler.
- desktop/src/main.ts: the Speaker button (previously toggled the
  playout-write gate via `toggle_speaker`) now calls `set_speakerphone`
  and reads back the new routing state. Labels switched from
  "Spk" / "Spk Off" to "Earpiece" / "Speaker" so users can't be
  confused into thinking clicking turns audio off. pollStatus no longer
  clobbers the spkBtn label based on engine spk_muted, since the two
  concepts are now decoupled.

WIP because this has NOT been built or tested yet — committing at night
to save the work. Tomorrow: build #50 with this change, smoke-test the
Handset↔Speaker toggle, then move on to call history + last-contacts UI
and the Speaker-button mute bug on the other phone.
2026-04-09 22:00:34 +04:00
Siavash Sameni
9e37201198 android(audio): Usage::VoiceCommunication + MODE_IN_COMMUNICATION, default handset
Some checks failed
Mirror to GitHub / mirror (push) Failing after 38s
Build Release Binaries / build-amd64 (push) Failing after 3m44s
With da106bd (Usage::Media + MODE_NORMAL) audio works but is always on
the loudspeaker — we want handset as the default with a user-driven
toggle for speaker (and later bluetooth). The right Oboe usage for a
VoIP app is VoiceCommunication, which honours
AudioManager.setSpeakerphoneOn / setBluetoothScoOn for routing.

Bisection across previous builds showed that setAudioApi(AAudio) +
Usage::VoiceCommunication made the playout callback stop draining the
ring after cb#0 (build 8c36fb5 logs). Letting Oboe pick the AudioApi
implicitly keeps the callback alive — 96be740's Media-usage callbacks
fired at steady 50Hz without any explicit setAudioApi. So: keep the
Usage change, DROP the explicit AAudio force.

- oboe_bridge.cpp: Usage::VoiceCommunication, no setAudioApi, no
  ContentType override.
- MainActivity.kt: setMode(MODE_IN_COMMUNICATION) +
  setSpeakerphoneOn(false) = handset default, plus max both
  STREAM_VOICE_CALL and STREAM_MUSIC volumes for belt-and-braces.

Next build will add a JNI-based Tauri command to flip speakerphoneOn
at runtime so the user can toggle handset↔speaker during a call.
2026-04-09 21:50:06 +04:00
Siavash Sameni
da106bd939 fix(android-audio): revert to 96be740's Oboe config — VoiceCommunication broke callback drain
Some checks failed
Mirror to GitHub / mirror (push) Failing after 40s
Build Release Binaries / build-amd64 (push) Failing after 3m45s
Build 8c36fb5 logs showed a new regression: Oboe playout cb#0 fires once
at startup then the callback STOPS DRAINING the ring entirely.
written_samples sticks at 7679 (= RING_CAPACITY - 1) across every recv
heartbeat in a 40-second test. Meanwhile the recv task decodes 1800+ real
audio frames (sample range up to [-27920..31907], rms 12065) which all
get dropped on the floor by audio_write_playout returning 0 because the
ring is full.

Bisection: 96be740 (Usage::Media, no setAudioApi, no ContentType, no
MainActivity audio mode change) DID drive the playout callback at the
expected 50Hz (playout heartbeat: calls=1100 total_played_real=1055040
over 22 seconds). User still heard nothing there because of OS routing,
but at least Oboe accepted the PCM.

8c36fb5 added three changes on top of 96be740:
  1. Oboe Usage::Media → Usage::VoiceCommunication
  2. Oboe setAudioApi(oboe::AudioApi::AAudio) explicit
  3. Oboe setContentType(ContentType::Speech)
  4. MainActivity setMode(MODE_IN_COMMUNICATION) + setSpeakerphoneOn(true)
Every one of those could have killed the callback; combined they did.

Revert to 96be740's exact Oboe config: Usage::Media, no setAudioApi, no
ContentType. Keep the PCM recorder, heartbeat logging, and stream-open
logging. Separately, MainActivity now maxes STREAM_MUSIC (the stream
Usage::Media routes to) but leaves audio mode in MODE_NORMAL — no more
speakerphone/call-mode combo that makes Oboe unhappy. In NORMAL mode a
STREAM_MUSIC stream plays through the loud speaker by default.

Proof that the Rust pipeline is perfect: decoded.pcm recorded in 8c36fb5
was pulled via `adb shell run-as com.wzp.desktop cat .wzp/decoded.pcm`,
converted with ffmpeg, and played back on the Mac — user confirmed
audible speech. So 100% of the remaining bug surface is Android audio
routing, not anything in the Rust/C++ decode path.
2026-04-09 21:38:19 +04:00
Siavash Sameni
cfa9ff67cf fix(android-audio): VoIP mode + speakerphone + debug PCM recorder
Some checks failed
Mirror to GitHub / mirror (push) Failing after 40s
Build Release Binaries / build-amd64 (push) Has been cancelled
Build 96be740 logs proved the entire software pipeline is healthy:
  capture heartbeat:   calls=1100 to_write=960 full_drops=0 total_written=1056000
  recv heartbeat:      decoded_frames=1035 last_written=960 decode_errs=0
  recv decoded PCM:    range=[-13564..9244] rms=8044 (real audio)
  playout WRITE:       in_len=960 written=960 rms=2318 (real audio into the ring)
  playout heartbeat:   calls=1100 nonempty=1099 total_played_real=1055040

1055040 samples / 48000 Hz = 22s — exactly matches wall-clock elapsed,
meaning Oboe IS calling our playout callback at the expected rate and
WE ARE handing it real PCM every 20ms. User still heard nothing. Ergo
Oboe accepted the PCM and routed it to a silent output. Two fixes:

1) MainActivity.kt: switch to MODE_IN_COMMUNICATION + speakerphone ON
   right after permissions are granted, and crank STREAM_VOICE_CALL to
   max. Without this, an Oboe Usage::VoiceCommunication stream gets
   opened, the OS creates a real AAudio pipeline, the callback fires on
   schedule — and audio goes to either the earpiece at muted volume or
   a "call not active" dead end. Logs the audio mode + volume levels
   before and after the switch so we can confirm the state change in
   logcat next run.

2) oboe_bridge.cpp: revert Usage::Media → VoiceCommunication (the mode
   that matches MODE_IN_COMMUNICATION), pin the audio API to AAudio
   explicitly instead of letting Oboe fall back to OpenSLES (which has
   its own silent-drop failure modes on some devices), and add getState
   + getXRunCount to the playout heartbeat so we'll see silent stream
   disconnects instead of reading zeros forever.

3) engine.rs recv task: dump the first ~10s of post-AGC decoded PCM to
   `<app_data_dir>/decoded.pcm` as raw i16 LE so we can adb pull it and
   play it back locally:
       adb shell run-as com.wzp.desktop cat .wzp/decoded.pcm > decoded.pcm
       ffmpeg -f s16le -ar 48000 -ac 1 -i decoded.pcm decoded.wav
   This divorces "is our decoder actually producing audible audio" from
   "is Android's audio stack playing it". If the recorded WAV sounds
   correct when played on a laptop, the decoder is fine and 100% of the
   remaining bug surface is AudioManager / Oboe routing.

4) engine.rs: also log when spk_muted=true blocks the write. User
   reported the Speaker button in the UI has inconsistent semantics
   between desktop and android — adding this log rules out the accidental
   "first click muted playback" theory for good.
2026-04-09 21:24:26 +04:00
Siavash Sameni
96be740fd9 diag(android-audio): aggressive logging across the whole Oboe pipeline
Some checks failed
Mirror to GitHub / mirror (push) Failing after 40s
Build Release Binaries / build-amd64 (push) Failing after 3m46s
User confirmed: mac hears android, android does not hear mac. So Oboe
capture works end-to-end but Oboe playout on Android silently drops
audio even though QUIC forwards the packets. Archaeology on the legacy
wzp-android crate also revealed that the "last known good" Android audio
path NEVER used Oboe in production — it used Kotlin AudioRecord +
AudioTrack via JNI, and cpp/oboe_bridge.cpp was dead code. So every time
we've "tested" Oboe end-to-end this week was the first production use,
and any of its config knobs could be the bug.

Instrumenting every stage of the pipeline so one smoke-test log dump can
isolate the layer at fault:

C++ (oboe_bridge.cpp)
  - Log the ACTUAL stream parameters after openStream for both capture
    and playout (sample rate, channels, format, framesPerBurst,
    framesPerDataCallback, bufferCapacityInFrames, sharing, perf mode).
    Oboe may silently override values we requested — e.g. if we ask for
    48kHz mono but the device gives us 44.1kHz stereo our 960-sample
    frames are the wrong duration and the pipeline drifts.
  - Capture callback: on cb#0 log sample range+RMS of the first frame
    to prove we get real mic data (not zeros). Every 50 callbacks
    (~1s at 20ms burst) log calls, numFrames, ring available_write,
    bytes actually written, ring_full_drops, total_written.
  - Playout callback: on cb#0 log numFrames + ring state. On the FIRST
    non-empty read log sample range+RMS so we can tell if the samples
    coming out of the ring are real audio or zeros. Every 50 callbacks
    log calls, nonempty count, numFrames, ring available_read,
    underrun_frames, total_played_real.

Rust wzp-native (src/lib.rs)
  - wzp_native_audio_write_playout now logs the first 3 writes and then
    every 50th: in_len, written, sample range, RMS, ring write/read
    cursors before, available_read and available_write after. Reveals
    ring-overflow and whether the engine is actually handing us audio.
  - Minimal android logcat shim via __android_log_write extern — no
    new crate dependency.
  - AudioBackend grows a `playout_write_log_count` AtomicU64 to gate
    the write-side log throttle.

Rust engine.rs (android branch)
  - Recv task: log sample range + RMS for the first 3 decoded PCM
    frames and then every 100th. Reveals whether decoder.decode is
    producing real audio or silent buffers.
  - Recv task: if audio_write_playout returns fewer samples than we
    handed it (partial write → ring nearly full) warn about it in the
    first 10 frames.
  - Recv heartbeat every 2s: recv_fr, decoded_frames, last_decode_n,
    last_written, written_samples, decode_errs, codec.

Expected flow in a healthy log:
  capture cb#0: numFrames=960 range=[-1200..900] rms=180          ← mic OK
  capture stream opened: actualSR=48000 Ch=1 ...                   ← no override
  playout stream opened: actualSR=48000 Ch=1 ...
  CallEngine::start invoked ... → connected → audio started
  recv: first media packet received ...
  recv: decoded PCM sample range decoded_frames=1 range=[-300..250] rms=92
  playout WRITE #0: in_len=960 written=960 range=[-300..250] rms=92
  playout FIRST nonempty read: to_read=960 range=[-300..250] rms=92
  playout heartbeat: calls=50 nonempty=50 underrun=0 ...
  recv heartbeat: decoded_frames=100 last_written=960 ...

If any of those are missing/zero we know the exact stage to fix.
2026-04-09 21:13:29 +04:00
Siavash Sameni
8c4d640f89 fix(android): playout Usage::Media + relay CallSetup advertises real IP
Some checks failed
Mirror to GitHub / mirror (push) Failing after 40s
Build Release Binaries / build-amd64 (push) Failing after 3m43s
Three real bugs, one smoke-test session's worth of progress.

1. RELAY: wrong advertised addr in CallSetup
   The direct-call CallSetup computed `relay_addr = addr.ip()` where
   `addr = connection.remote_address()` — i.e. the CLIENT'S IP, not the
   relay's. So the relay was telling both parties "the call room is at
   the answerer's IP:4433", which meant each client dialed either the
   other client (no server listening) or themselves. Both endpoint.connect
   calls hung forever and the call never happened.
   Fix: compute the relay's own advertised IP once at startup. If the
   listen addr is 0.0.0.0, probe the primary outbound interface via the
   classic UDP-bind-and-connect(8.8.8.8:80) trick to discover the LAN
   IP the OS would use to reach external hosts. Thread the resulting
   advertised_addr_str into the CallSetup sender for both parties.

2. RELAY: accept loop serialized QUIC handshakes
   Previously the main accept loop called `wzp_transport::accept` which
   did both `endpoint.accept().await` AND `incoming.await` (the server-
   side QUIC handshake). A single slow handshake therefore blocked every
   subsequent client from being accepted. Unroll the helper here and
   move `incoming.await` into the per-connection spawned task, so every
   handshake runs in parallel. Also log "accept queue: new Incoming",
   "QUIC handshake complete", and "QUIC handshake failed" so we can tell
   immediately whether a client's packets are reaching the relay at all.

3. ANDROID: playout was routed to the silent in-call stream
   The Oboe playout stream was configured with Usage::VoiceCommunication,
   which routes to the Android in-call earpiece stream. That stream is
   silent unless the Activity has called AudioManager.setMode(
   IN_COMMUNICATION) and, even then, only the earpiece/BT headset get
   audio (not the loud speaker). Result: android→mac calls worked
   because mac had a normal media output, but mac→android calls were
   silent even though packets flowed through the relay just fine.
   Switch to Usage::Media + ContentType::Speech so Oboe routes to the
   loud speaker and uses the media volume slider. A later polish step
   will wire setMode + setSpeakerphoneOn from MainActivity.kt so we can
   go back to VoiceCommunication for AEC and proximity-sensor routing.

Plus: heartbeat tracing every 2s in the send/recv tasks — frames_sent,
last_rms, last_pkt_bytes, short_reads on the send side; decoded_frames,
last_decode_n, last_written, decode_errs on the recv side. Will make the
next "no sound" regression trivial to localize.
2026-04-09 20:55:10 +04:00
Siavash Sameni
49f101d785 fix(android): reuse signal endpoint for direct-call media connection
Some checks failed
Mirror to GitHub / mirror (push) Failing after 38s
Build Release Binaries / build-amd64 (push) Failing after 3m46s
Direct-call accept hangs forever at the QUIC handshake on Android. Logs
from d7b37a5 showed:
  CallEngine::start (android) invoked relay=172.16.81.172:4433 room=call-…
  resolved relay addr
  identity loaded
  endpoint created, dialing relay   ← reached
                                    ← nothing, 90s+, no error
The "connect failed" and "QUIC connection established" log lines never
fire, meaning endpoint.connect_with(…).await never makes progress.

Repro is 100%: SFU room join (one endpoint) works perfectly; direct call
(opens a SECOND quinn::Endpoint on top of the signal one) hangs in the
QUIC handshake. Creating two quinn::Endpoints on Android's AAudio-adjacent
UDP stack apparently causes the second one's datagrams to never reach the
relay (the server never sees the Initial packet). Rather than fight the
platform, quinn is happy to multiplex multiple Connections on a single
Endpoint — so we reuse the signal endpoint for the media connection.

- SignalState now stores the quinn::Endpoint alongside the QuinnTransport.
  register_signal populates both at the same time.
- CallEngine::start (both android and desktop branches) takes an
  Option<wzp_transport::Endpoint>. Some → reuse (direct-call path, after
  register_signal). None → create fresh (SFU room join path).
- The connect tauri command reads state.signal.endpoint and threads it
  through to CallEngine::start, so the direct-call auto-connect (fired by
  the "setup" signal-event in main.ts) lands on the existing UDP socket.
- wzp_transport re-exports quinn::Endpoint so wzp-desktop doesn't need to
  depend on quinn directly.
- Also wraps the android connect in tokio::time::timeout(10s) so future
  hangs become deterministic "connect TIMED OUT" errors in logcat
  instead of silent deadlock.

Same fix applies verbatim to the desktop client — the user suspects
direct call is broken there too and this was likely always the cause,
just never surfaced because desktop was only tested via SFU rooms.
2026-04-09 20:29:51 +04:00
Siavash Sameni
d7b37a5749 diag: tracing for direct-call signal loop + CallEngine::start stages
Some checks failed
Mirror to GitHub / mirror (push) Failing after 38s
Build Release Binaries / build-amd64 (push) Failing after 3m57s
User reports tapping "answer" on an incoming direct call does nothing
visible, and suspects the same may affect desktop. The signal recv loop
had no tracing at all, so we can't tell whether CallSetup is being
received, whether the recv loop died silently, or whether
CallEngine::start is failing between "identity loaded" and
"connected to relay, handshake complete".

- register_signal recv loop now logs every message type with fields
  (CallRinging, DirectCallOffer, DirectCallAnswer, CallSetup, Hangup,
  unhandled), plus a warn! on recv errors and a final warn when the
  loop exits.
- place_call / answer_call commands log entry + success / error. The
  answer_call error path logs the underlying send_signal error so we
  can see it in logcat instead of only in the JS error toast.
- CallEngine::start android branch logs relay/room/alias on entry,
  logs "endpoint created, dialing relay" between create_endpoint and
  connect, "QUIC connection established, performing handshake" between
  connect and perform_handshake, and promotes all three potential
  failures to explicit error! logs so a silent hang / error becomes
  visible in logcat.

No functional changes — pure diagnostics. Stacks on b35a6b7 (the Oboe
stack-pointer-escape fix) so build #43 carries both.
2026-04-09 19:17:03 +04:00
Siavash Sameni
0105b0fbf3 phase 3(android): RECORD_AUDIO permission + runtime request in MainActivity
Some checks failed
Mirror to GitHub / mirror (push) Failing after 36s
Build Release Binaries / build-amd64 (push) Failing after 4m0s
Oboe fails silently to open the AAudio input stream without
android.permission.RECORD_AUDIO, so the call audio would never actually
flow even after phase 3's engine wiring.

- AndroidManifest.xml: declare RECORD_AUDIO and MODIFY_AUDIO_SETTINGS, and
  android.hardware.microphone as a required feature. These files are the
  cargo-tauri-generated scaffold — nothing in .gitignore excludes them, so
  the intended Tauri 2 mobile workflow is to commit them once populated.

- MainActivity.kt: override onCreate to call ActivityCompat.requestPermissions
  for the audio perms on first launch. The dialog shows exactly once; the
  grant is persisted per-package. onRequestPermissionsResult logs the
  outcome so we can spot failures in logcat.

A full native Tauri permission plugin integration is deferred to
Step 6 (polish) together with notifications, icon, and background service.
2026-04-09 19:00:12 +04:00
Siavash Sameni
5beea7de40 phase 3(android): unify connect/disconnect/toggle_*/get_status commands
Some checks failed
Mirror to GitHub / mirror (push) Failing after 37s
Build Release Binaries / build-amd64 (push) Failing after 3m49s
Step 3 of the Tauri Android rewrite was still returning "audio backend not
yet wired on Android (step 3)" because the cfg-gated Android stubs for
connect/disconnect/toggle_mic/toggle_speaker/get_status were shadowing the
real commands. Now that CallEngine::start() has a real Android body (phase
3, commit fdbe502), the gates are unnecessary.

- Drop the #[cfg(not(target_os = "android"))] gates from all five
  engine-backed Tauri commands.
- Delete the Android stub block (~50 LOC of "not connected" boilerplate).
- Ungate `use engine::CallEngine;` and the AppState.engine field so both
  targets share the same Mutex<Option<CallEngine>>.
- CallEngine::stop() now calls crate::wzp_native::audio_stop() on Android so
  the mic + speaker are released between calls, matching the desktop
  behaviour where dropping _audio_handle tears down CPAL.

Direct-call flow on Android: peer sends DirectCallOffer → user accepts via
answer_call → relay sends signal "setup" event → main.ts auto-invokes
connect(relay, room) → CallEngine::start() runs the Android branch →
wzp_native::audio_start() brings up Oboe → send/recv tasks stream PCM
through the dlopen boundary.
2026-04-09 18:53:54 +04:00
Siavash Sameni
fdbe502524 phase 3(android): wire CallEngine::start to wzp-native audio FFI
Some checks failed
Mirror to GitHub / mirror (push) Failing after 39s
Build Release Binaries / build-amd64 (push) Failing after 3m57s
Replaces the Android-side CallEngine::start() stub with a real implementation
that mirrors the desktop start() body but routes all PCM through the
standalone wzp-native cdylib loaded at startup via libloading instead of
using CPAL.

- desktop/src-tauri/src/wzp_native.rs: new module with a static
  OnceLock<libloading::Library> + cached raw fn pointers for every symbol
  we need (version, hello, audio_start/stop, read_capture, write_playout,
  is_running, capture/playout_latency_ms). init() resolves everything once
  at startup; accessors return default values if init() never ran.

- desktop/src-tauri/src/lib.rs: drop the inline dlopen smoke test, add
  `mod wzp_native;` behind target_os="android", and invoke
  wzp_native::init() from the Tauri setup() callback so the library is
  loaded + all symbols cached before any CallEngine can touch audio.

- desktop/src-tauri/src/engine.rs: the Android #[cfg] branch of
  CallEngine::start() now does the full QUIC handshake + signal loop +
  Opus send/recv tasks, calling wzp_native::audio_start() /
  audio_read_capture() / audio_write_playout() instead of the desktop
  CPAL rings. SyncWrapper now holds a placeholder Box<()> on Android
  because the audio backend lives in a process-global singleton inside
  libwzp_native.so rather than being owned per-engine.

Next step: build #39 on the remote docker builder and smoke-test on
Pixel 6 that the Connect button in the UI successfully brings up Oboe
and streams audio through the dlopen boundary.
2026-04-09 18:42:27 +04:00
Siavash Sameni
7cc53aedc7 refactor(android): split C++ into wzp-native cdylib, loaded at runtime
Some checks failed
Mirror to GitHub / mirror (push) Failing after 38s
Build Release Binaries / build-amd64 (push) Failing after 3m34s
Phase 1 of the big refactor. Escape the Tauri Android
__init_tcb+4 symbol leak (rust-lang/rust#104707) by making
wzp-desktop's Android .so pure Rust — ZERO cc::Build, no cpp/ files,
no C++ in the rustc link step. All future C++ (Oboe audio bridge)
lives in a new standalone cdylib crate `wzp-native` which is built
with cargo-ndk (the same path the legacy wzp-android crate uses
successfully on the same phone + same NDK), copied into Tauri's
gen/android/app/src/main/jniLibs at build time, and dlopened by
wzp-desktop at runtime via libloading.

Changes in this commit:
- NEW crate crates/wzp-native/ with crate-type = ["cdylib"] only
  (no staticlib, no rlib — rust#104707 shows mixing staticlib with
  cdylib leaks non-exported symbols, which is the original bug
  source). Phase 1 scaffold has TWO extern "C" functions:
    wzp_native_version() -> i32            (returns 42)
    wzp_native_hello(buf, cap) -> usize    (writes a string)
  So we can verify dlopen + dlsym + cross-.so FFI end-to-end
  before adding any real C++.
- desktop/src-tauri/cpp/ directory DELETED (7 files gone).
- desktop/src-tauri/build.rs reduced to just the git hash capture
  + tauri_build::build(). No more cc::Build of any kind.
- desktop/src-tauri/Cargo.toml: drop cc from build-dependencies,
  add libloading = "0.8" as an Android-only runtime dep.
- desktop/src-tauri/src/lib.rs Builder::setup() now (on Android only)
  dlopens libwzp_native.so, calls wzp_native_version() and
  wzp_native_hello(), and logs the result:
    "wzp-native dlopen OK: version=42 msg=\"hello from wzp-native\""
  If this log appears in logcat when the app launches and the home
  screen still renders, the split-cdylib pipeline is validated and
  Phase 2 (port the Oboe bridge into wzp-native) can proceed.
- scripts/build-tauri-android.sh: insert a `cargo ndk -t arm64-v8a
  build --release -p wzp-native` step before `cargo tauri android
  build`, with `-o desktop/src-tauri/gen/android/app/src/main/jniLibs`
  so the resulting libwzp_native.so lands in the place gradle will
  package into the final APK.
- Workspace Cargo.toml: add crates/wzp-native to [workspace] members.

Phase 2 (separate commit, only if Phase 1 works):
- Copy cpp/oboe_bridge.{h,cpp} + getauxval_fix.c from the legacy
  wzp-android crate into crates/wzp-native/cpp/.
- Add cc = "1" as a build-dependency on wzp-native (safe: it's a
  single-cdylib crate with no staticlib, so no symbol leak).
- Add build.rs that compiles the Oboe C++ and the wzp-native Rust
  FFI exposes the audio start/stop/read/write functions.
- wzp-desktop::engine.rs dlopens wzp-native at CallEngine::start,
  uses its audio functions instead of CPAL on Android.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-09 18:02:53 +04:00
Siavash Sameni
711137da96 fix(android): -Wl,--exclude-libs,ALL + --no-whole-archive to stop symbol leak
Some checks failed
Mirror to GitHub / mirror (push) Failing after 36s
Build Release Binaries / build-amd64 (push) Failing after 3m54s
llvm-nm on the crashing .so confirmed the research's smoking gun theory:

  000000000130c1f0 t _Z10__init_tcbP10bionic_tcbP18pthread_internal_t
  0000000000000000 a pthread_create.cpp
  0000000001331108 t pthread_create

All lowercase 't' (= LOCAL text symbols), zero UND dynamic references
for pthread_create. So rustc's link step is pulling bionic's own
pthread_create.cpp compilation unit out of libc.a as a whole-archive
inclusion and binding those symbols locally inside our .so, instead
of letting them stay UND and resolved against libc.so at dlopen time.

Rust's libstd thread::spawn then calls the LOCAL (broken) pthread_create
which calls the LOCAL __init_tcb with arguments set up for bionic's
static-executable layout — crashes at __init_tcb+4 with SEGV_ACCERR.

`-Wl,--exclude-libs,ALL` tells the linker to make symbols from static
archives NOT appear in the dynamic symbol table of the output .so.
`-Wl,--no-whole-archive` tells it to only pull archive objects that
satisfy undefined references, not include the whole archive blindly.

If this works, the symbol table should show pthread_create as UND
(or at least not locally bound) and the app should launch. If it
doesn't, the remaining fallback is the research's action #3 —
extract the C++ into its own upstream cdylib crate built with
cargo-ndk, and dlopen it from the Tauri cdylib at runtime.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-09 17:45:35 +04:00
Siavash Sameni
6071eb1b02 fix(android): drop staticlib from crate-type — root cause of __init_tcb crash
Some checks failed
Mirror to GitHub / mirror (push) Failing after 42s
Build Release Binaries / build-amd64 (push) Failing after 3m47s
External research (per rust-lang/rust#104707) pointed at this as the
highest-probability cause of our byte-identical __init_tcb+4 /
pthread_create SIGSEGVs:

> Having 'staticlib' alongside 'cdylib' in crate-type leaks non-exported
> symbols from the staticlib into the cdylib's symbol table. For a
> Tauri Android cdylib, that means bionic's private pthread_create /
> __init_tcb code — which got pulled in statically from libc.a the
> moment any cc::Build C++ file added C++-linkage overhead — ends up
> bound LOCALLY inside our .so instead of being resolved dynamically
> against libc.so at dlopen time.

Symptoms that match the theory exactly:
- llvm-nm on the crashing .so shows __init_tcb and pthread_create as
  LOCAL symbols with C++ name mangling (bionic's own pthread_create.cpp)
- Adding any cc::Build cpp(true) step reliably triggers the crash,
  independent of which linker (android24-clang vs android26-clang) or
  which libc++ linkage (shared/static/none)
- The legacy wzp-android crate (["cdylib", "rlib"]) works fine on the
  same phone with the same NDK + Rust toolchain + Oboe C++ code
- tauri.conf.json bundle.android.minSdkVersion=26 propagates to
  gradle but the .so still crashes byte-identically

Drop 'staticlib' from crate-type. If we ever need it for iOS, re-add
behind a target.'cfg(target_os = "ios")' gate. The desktop binary
still links against the rlib, so the bin target on macOS/Linux/Windows
is unaffected.

Source: https://github.com/rust-lang/rust/issues/104707

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-09 17:38:49 +04:00
Siavash Sameni
c9cd043657 test: tauri.conf.json bundle.android.minSdkVersion=26 + cpp_smoke.cpp c++_shared
Some checks failed
Mirror to GitHub / mirror (push) Failing after 1m26s
Build Release Binaries / build-amd64 (push) Failing after 3m37s
User theory: tauri-cli hardcodes minSdkVersion=24 into its rustc
invocation regardless of gradle build.gradle.kts, .cargo/config.toml,
or env var overrides — but DOES read from tauri.conf.json's
bundle.android block. That would explain why every cc::Build C++
compile crashed with __init_tcb+4 via pthread_create: API-24 bionic's
.init_array routines for the linked-in .init_array clash with the
pthread_create state tao later expects.

This commit applies the fix AND re-adds the smallest known crashing
variant (E.1 with cpp_link_stdlib('c++_shared')) so the test has one
clear failure mode to compare against:

  tauri.conf.json bundle:
    "android": { "minSdkVersion": 26 }

  build.rs (on android target):
    - hello.c           (plain C, worked in Step A)
    - getauxval_fix.c   (plain C, worked in Step D)
    - hello2.c          (plain C, worked in Step D+1)
    - cpp_smoke.cpp     (C++ via cc::Build .cpp(true), crashed in E.1)

Also re-emits the libc++_shared.so copy into gen/android jniLibs so
the runtime linker can resolve the NEEDED entry cc-rs added via
cpp_link_stdlib('c++_shared').

If this launches → theory validated, proceed with Oboe integration.
If this crashes → need to keep digging.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-09 16:58:37 +04:00
Siavash Sameni
6dd62c94c9 step D+1: add third trivial C static lib (hello2.c)
Some checks failed
Mirror to GitHub / mirror (push) Failing after 38s
Build Release Binaries / build-amd64 (push) Failing after 3m51s
Step D (hello.c + getauxval_fix.c) launches cleanly. E.minus-1
(hello.c + getauxval_fix.c + cpp_smoke.c) crashes. All three are
plain-C trivial single-function files.

Theory: the regression is triggered by having 3 or more cc::Build
static libs in a Tauri Android cdylib, regardless of what the libs
contain. Test: clone hello.c as hello2.c (same content, different
symbol) and add a third cc::Build step compiling it. If this crashes,
the trigger is just the number of static libs. If it launches, there's
something magical about cpp_smoke.c specifically (unlikely — it was
near-identical content).

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-09 16:51:50 +04:00
Siavash Sameni
4c998312aa regression check: revert build.rs to exact Step D state
Some checks failed
Mirror to GitHub / mirror (push) Failing after 37s
Build Release Binaries / build-amd64 (push) Failing after 3m37s
Verify the Step D baseline still launches after the environment mutations
we may have caused during the E bisection (docker image rebuild, tauri-cli
version drift, etc). Build.rs is now byte-identical to commit a852cad
(Step D) except for the git hash capture block that already existed at
that point.

If this launches cleanly → the cpp_smoke addition genuinely breaks
something, bisection continues.
If this crashes → the environment regressed between Step D and now,
and we need to rebuild the docker image to an earlier snapshot.
2026-04-09 16:45:34 +04:00
Siavash Sameni
22701830c2 step E.minus-1: cpp_smoke renamed to .c and compiled as plain C
Some checks failed
Mirror to GitHub / mirror (push) Failing after 39s
Build Release Binaries / build-amd64 (push) Failing after 3m53s
c++_shared crashed, c++_static crashed, no stdlib crashed. The remaining
variable isolated to cc::Build::new().cpp(true) itself is the C++
compile-mode invocation of clang++. Rename cpp_smoke.cpp → cpp_smoke.c
and drop .cpp(true), leaving a plain-C cc::Build that compiles the
exact same bytes (minus the 'extern "C"' linkage spec which is C++-
only syntax).

This is structurally identical to Step A (hello.c), which worked. If
THIS build launches, the diff between 'works' and 'crashes' is purely
the .cpp(true) mode — something clang++ does differently at compile
or link time when producing object files for a Tauri Android cdylib.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-09 16:38:29 +04:00
Siavash Sameni
47a037368c step E.0: drop cpp_link_stdlib entirely (no libc++ linkage)
Some checks failed
Mirror to GitHub / mirror (push) Failing after 36s
Build Release Binaries / build-amd64 (push) Failing after 3m47s
c++_shared crashed. c++_static also crashed. Both have libc++ code
landing in the final .so — one as a NEEDED dynamic lib, the other
bundled statically. So the trigger isn't the NEEDED entry specifically,
it's libc++ being present in any form.

cpp_smoke.cpp is just 'extern "C" int wzp_cpp_hello() { return 42; }'
with zero C++ features used, so we can drop cpp_link_stdlib completely
and the compile still succeeds. No libc++ .a or .so referenced at all.

If this crashes: the trigger is cc::Build::new().cpp(true) switching
rustc's final linker driver from clang to clang++ (which pulls in
different default libraries).

If this launches: the trigger is libc++'s own static initializers or
the libc++ code itself doing something that breaks our .so at dlopen
time, and we have a path forward — C++ code that doesn't need libc++
(e.g., a thin C++ bridge to Oboe that uses only POD types at the
boundary, with all the STL stuff confined to Oboe's own compilation
unit which would still need libc++...). More likely we still need a
C-only audio interface like raw AAudio via the ndk Rust crate.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-09 16:31:53 +04:00
Siavash Sameni
191e8761d5 step E.1 variant: cpp_link_stdlib c++_shared → c++_static
Some checks failed
Mirror to GitHub / mirror (push) Failing after 36s
Build Release Binaries / build-amd64 (push) Failing after 3m42s
Every E.x variant crashed identically when linked with c++_shared, even
with a 3-line cpp file that's dead-stripped from the final .so. The
crash offsets are byte-identical across E.1, E.2, E.4, and the original
full-Oboe Step E. That points at a non-code link-time delta: the
`cargo:rustc-link-lib=c++_shared` directive that adds a NEEDED entry
for libc++_shared.so to the .so's dynamic table.

Swap to c++_static — bundles libc++ directly into our .so so the
NEEDED entry disappears. If this launches cleanly, we've conclusively
proven the NEEDED libc++_shared.so is the root cause and we have a
workable linkage for any C++ we want to add to the Tauri Android build
(including the eventual Oboe audio backend).

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-09 16:18:04 +04:00
Siavash Sameni
0d74366592 step E.1: absolute minimum C++ file (no STL, no includes)
Some checks failed
Build Release Binaries / build-amd64 (push) Failing after 3m53s
Last bisection step. cpp/cpp_smoke.cpp reduced to a single extern 'C'
function that returns 42. No #include, no std::atomic, no std::mutex,
no std::thread. Only C++ things remaining are:
  - cc::Build::new().cpp(true) in build.rs (C++ mode compile)
  - cpp_link_stdlib('c++_shared') emitting -lc++_shared

If this still crashes with the same __init_tcb+4 / pthread_create
stack, we've conclusively proven the trigger is NOT any C++ code
that ends up in the final .so (everything gets dead-stripped
anyway because Rust never references wzp_cpp_hello). The trigger
must be either:
  a) cargo:rustc-link-lib=c++_shared (adds NEEDED entry for
     libc++_shared.so in the .so's dynamic table, causing the
     dynamic linker to load libc++_shared.so at dlopen() time
     alongside our .so), or
  b) Some interaction between cpp(true) mode and the rest of the
     build pipeline (toolchain flags, symbol visibility, etc.)

After this build we stop and write an incident report for the
WarzonePhone Tauri Android rewrite bisection so far.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-09 15:54:21 +04:00
Siavash Sameni
0224ce654c step E.2: shrink cpp_smoke to std::atomic only — no thread, no mutex
Some checks failed
Mirror to GitHub / mirror (push) Failing after 38s
Build Release Binaries / build-amd64 (push) Failing after 3m48s
Incremental bisection within Step E. E.4 (atomic + mutex + thread) still
crashed at __init_tcb. Drop mutex and thread, keep only std::atomic.
Build.rs still emits cargo:rustc-link-lib=c++_shared via
cpp_link_stdlib('c++_shared'), so the NEEDED entry for libc++_shared.so
in the final .so stays identical. Goal: if this crashes, the issue is
purely the dynamic link against libc++_shared (not thread/mutex code).
If it passes, the issue is actually std::thread or std::mutex use.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-09 15:47:30 +04:00
Siavash Sameni
aa240c6d83 step E.4(android): replace full Oboe compile with minimal C++ smoke file
Some checks failed
Mirror to GitHub / mirror (push) Failing after 40s
Build Release Binaries / build-amd64 (push) Failing after 3m44s
Bisection for the __init_tcb+4 crash that Step E introduced: drop the
full Oboe C++ build (200+ files, hundreds of KB of code) and replace
it with ONE tiny cpp/cpp_smoke.cpp that exercises the libc++ features
Oboe uses — std::atomic, std::mutex, std::thread — via an
extern "C" wzp_cpp_smoke() function that's exported but NEVER called
from Rust.

Still compiled with cpp_link_stdlib("c++_shared"), same as Oboe.
libc++_shared.so still copied into gen/android jniLibs. But no Oboe
headers, no Oboe source files, no -llog / -lOpenSLES links.

Hypothesis: if cpp_smoke.cpp alone reproduces the __init_tcb crash,
the trigger is "any libc++_shared link that references
std::thread/std::mutex" and Oboe is not the specific culprit. If it
launches cleanly, Oboe itself (its size, its static constructors, or
a specific header) is responsible — and we then bisect Oboe's
source tree.

fetch_oboe() and add_cpp_files_recursive() are retained in build.rs
with #[allow(dead_code)] so re-enabling the full Oboe compile is a
one-line edit once we've identified what's safe to include.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-09 15:39:30 +04:00
Siavash Sameni
4250f1b44a step E(android): compile full Oboe C++ bridge (not yet called from Rust)
Some checks failed
Mirror to GitHub / mirror (push) Failing after 40s
Build Release Binaries / build-amd64 (push) Failing after 3m53s
Fifth incremental variable — and the first genuinely heavy one. Adds:
  - cpp/oboe_bridge.{h,cpp} (copied verbatim from crates/wzp-android/cpp/)
  - cpp/oboe_stub.cpp (fallback if Oboe can't be fetched)
  - build.rs now clones google/oboe@1.8.1 into OUT_DIR and compiles
    oboe_bridge.cpp + every .cpp file under oboe/src/ as a single
    static library via cc::Build, using shared libc++. Same logic as
    the legacy wzp-android build.rs.
  - libc++_shared.so gets copied from the NDK sysroot into the Tauri
    gen/android jniLibs directory so the runtime linker can find it.
  - rustc-link-lib=log / OpenSLES emitted for Oboe's Android backends.

Deliberately NOT called from Rust yet — no extern "C" FFI declarations,
no oboe_audio.rs module, the `wzp_oboe_*` symbols from the static lib
are simply present but unreferenced.

Goal: isolate whether the Oboe C++ compile + static lib link alone
(with its libc++ dependency and log/OpenSLES bindings) regresses the
working baseline. If the build still launches and renders the home
screen, we know the C++ side is clean and the actual regression is
caused by calling into Oboe at runtime (next step).

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-09 15:09:16 +04:00
Siavash Sameni
a852cad15e step D(android): compile cpp/getauxval_fix.c alongside hello.c
Some checks failed
Mirror to GitHub / mirror (push) Failing after 38s
Build Release Binaries / build-amd64 (push) Failing after 3m55s
Fourth incremental variable. Adds the getauxval_fix.c shim from the
legacy wzp-android crate (which has been shipping with it for months
without issue) to our cc::Build on Android. The file defines a single
getauxval() function that delegates to bionic's real runtime
implementation via dlsym — this is needed because rustc links
compiler-rt's broken static getauxval stub that SIGSEGVs in .so
libraries loaded via dlopen (reads __libc_auxv which is NULL).

Not imported from Rust. Goal: verify that adding a second C static
archive (and especially one that overrides a libc-ish symbol) doesn't
regress the working build.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-09 15:03:37 +04:00
Siavash Sameni
19fd3dd9cc step C fix: ungate wzp_proto imports used by resolve_quality() on Android
Some checks failed
Mirror to GitHub / mirror (push) Failing after 38s
Build Release Binaries / build-amd64 (push) Failing after 3m48s
Build #20 failed to compile on Android because I over-gated the
wzp_proto imports to non-Android. resolve_quality() is compiled on
every platform (it's outside the CallEngine impl) and references
QualityProfile + CodecId — both platform-independent types from
wzp_proto. Move those back to an unconditional import. tracing stays
gated (only the desktop start() body logs; the Android stub is silent).

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-09 14:59:00 +04:00
Siavash Sameni
c69195fe06 step C(android): compile engine.rs on Android with a stub CallEngine::start
Some checks failed
Mirror to GitHub / mirror (push) Failing after 36s
Build Release Binaries / build-amd64 (push) Has been cancelled
Third incremental variable. Previously the engine module was cfg-gated
out of the Android build entirely (`#[cfg(not(target_os = "android"))]
mod engine;` in lib.rs). Now it's always compiled, so any link-time
effect of having engine.rs in the compilation unit can be measured
against the working baseline from build #19.

Changes kept deliberately small:
- lib.rs: drop the cfg gate on `mod engine;`. `use engine::CallEngine`
  stays gated because the Android-specific connect/disconnect/... stubs
  in lib.rs don't reference the type.
- engine.rs: the `wzp_client::{audio_io, call}` imports + CodecId +
  QualityProfile are gated to non-Android (they require the `audio`
  feature on wzp-client which Android doesn't pull in). On Android we
  keep only the MediaTransport import for transport.close(). The impl
  block now has two `start()` methods: the full CPAL-backed one for
  desktop, and a 6-line Android stub that returns `Err("audio engine
  not yet wired on Android")` so attempts to `connect` from the UI
  fail cleanly.

Goal: verify that linking in the compiled engine module (plus the
types it references) on Android doesn't regress the working baseline.
Home screen should still render and register_signal should still work.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-09 14:56:02 +04:00
Siavash Sameni
ae4f366b05 step B(android): depend on wzp-client with default-features=false
Some checks failed
Mirror to GitHub / mirror (push) Failing after 39s
Build Release Binaries / build-amd64 (push) Failing after 3m54s
Second incremental variable on the path to Oboe. Adds a
`[target.'cfg(target_os = "android")'.dependencies]` block that pulls
in wzp-client with NO features enabled — no audio (no CPAL), no vpio
(no VoiceProcessingIO). This gives the Android build access to
wzp-client's platform-independent modules (call, handshake, audio_ring,
codec wiring) without any system audio bindings.

Deliberately no new imports in lib.rs or engine.rs. The only effect
should be: cargo-tauri on Android now has to compile wzp-client and
all its transitive crates (wzp-codec, wzp-fec, wzp-proto, wzp-crypto
already pulled directly; now also audiopus, raptorq, etc.) and link
them into libwzp_desktop_lib.so.

Goal: verify that merely expanding the compiled code set to include
wzp-client doesn't regress the previous working state. If it does, we
know one of wzp-client's transitive deps is the problem — probably a
C dep like audiopus or codec2.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-09 14:49:49 +04:00
Siavash Sameni
f96d7ce3e1 step A(android): add cc=1 build-dep + compile single trivial hello.c
Some checks failed
Mirror to GitHub / mirror (push) Failing after 37s
Build Release Binaries / build-amd64 (push) Failing after 3m54s
First incremental variable on the path back to Oboe integration. Changes
are deliberately minimal: add cc = "1" to [build-dependencies] (cargo
build-deps resolve against the host so the line is unconditional), and
on the Android target run a single cc::Build step that compiles
cpp/hello.c — a 6-line file that defines one function (`wzp_hello_stub`)
that is never called from Rust.

Goal: verify that merely introducing a C static library into the .so
via cc::Build does not regress the working build (#17, commit 5309938
= build #6 behaviour: launches, renders home screen, registers on
relay). If this build still works, we know cc::Build pipelines alone
are fine and can move to the next variable.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-09 14:45:24 +04:00
Siavash Sameni
530993854f revert(android): roll back to build #6 (35642d1) — pre-oboe known-good state
Some checks failed
Mirror to GitHub / mirror (push) Failing after 36s
Build Release Binaries / build-amd64 (push) Failing after 3m51s
Spent 10+ builds chasing a __init_tcb+4 / pthread_create SIGSEGV after
adding the oboe audio backend. Every "fix" made things worse. Reverting
all Android-specific files to the state at 35642d1 (build #6), which
was the last commit where the Tauri Android app actually launched,
rendered the home screen, and successfully registered on a relay.

Reverted files (all back to their 35642d1 content):
  - desktop/src-tauri/Cargo.toml        (no build-dep cc, no tracing-android)
  - desktop/src-tauri/build.rs          (git hash only, no Oboe / cc build)
  - desktop/src-tauri/src/lib.rs        (engine cfg-gated on non-android)
  - desktop/src-tauri/src/main.rs       (two-line desktop entry)
  - desktop/src-tauri/src/engine.rs     (desktop-only audio setup)
  - scripts/Dockerfile.android-builder  (no android24→26 clang shim)
  - scripts/build-tauri-android.sh      (no linker env vars / manifest patch)

Deleted (were added between b314138 and e2e023d):
  - desktop/src-tauri/cpp/getauxval_fix.c
  - desktop/src-tauri/cpp/oboe_bridge.{h,cpp}
  - desktop/src-tauri/cpp/oboe_stub.cpp
  - desktop/src-tauri/src/oboe_audio.rs

Next: rebuild image on remote (to drop the baked-in clang shim), build
an APK, install on Pixel 6, verify the UI renders the same way build #6
did. From there we add features back ONE at a time so we can actually
bisect which one triggers the tao::ndk_glue crash. User's rule:
"if you want to change stack, change incrementally, so we can debug".

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-09 14:22:57 +04:00
Siavash Sameni
e2e023d2bc fix(android): drop pthread_shim — clang shim makes it unnecessary (and harmful)
Some checks failed
Mirror to GitHub / mirror (push) Failing after 37s
Build Release Binaries / build-amd64 (push) Failing after 3m49s
Once the Dockerfile rewrites every android24-clang to exec android26-clang,
the linker uses the API-26 NDK sysroot and libstd's pthread_create reference
resolves directly against libc.so's real runtime symbol — no interposition
needed.

The pthread_shim.c approach was actually fighting its own solution: our
shim's dlsym() call bound at link time to libdl.a's STUB dlsym (a
five-line function inside libdl_static.o that just returns NULL and sets
dlerror to "libdl.a is a stub --- use libdl.so instead"). NDK r19 and
glibc 2.34 both replaced libdl.a with empty stubs because dynamic loading
is now part of the main libc/bionic — so no amount of link-order
tinkering can make a static libdl.a dlsym actually work.

Remove pthread_shim.c, the cc::Build::new().file("cpp/pthread_shim.c")
step in build.rs, and the -Wl,--wrap=pthread_create rustc-link-arg. Keep
getauxval_fix.c because that one DOES work at link time (the symbol
override is for a function compiler-rt defines statically, not one that
would depend on the stub libdl.a/libc.a).

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-09 13:52:53 +04:00
Siavash Sameni
1a8288c95f debug(android): instrument pthread_shim with logcat tracing + try RTLD_DEFAULT first
Some checks failed
Mirror to GitHub / mirror (push) Failing after 38s
Build Release Binaries / build-amd64 (push) Failing after 3m43s
Build #11 linked cleanly with --wrap=pthread_create but crashed at launch
on tao::ndk_glue::create with a Rust .expect() panic — meaning the shim's
__wrap_pthread_create successfully intercepted the call but returned
non-zero, triggering std::thread::spawn's Result::expect panic.

Add __android_log_print tracing so logcat shows exactly which resolver
path fired (RTLD_DEFAULT vs dlopen fallback) and what dlerror reports
when they fail. Also try RTLD_DEFAULT first — it's the simplest and
should find libc.so's pthread_create in the process's global symbol
table without any namespace games.
2026-04-09 13:15:47 +04:00
Siavash Sameni
f015be63ec fix(android): use --wrap=pthread_create instead of raw symbol override
Some checks failed
Mirror to GitHub / mirror (push) Failing after 38s
Build Release Binaries / build-amd64 (push) Failing after 3m39s
Build #10 failed with:
  ld.lld: error: duplicate symbol: pthread_create
    >>> defined at pthread_shim.c:30
    >>> ... in archive libpthread_shim.a
  (the other definition coming from libstd's bundled libc.a stub)

The raw-symbol-override approach was naive: when two static archives
both define the same symbol the linker refuses instead of picking one.

Switch to GNU-ld's `--wrap=pthread_create` mechanism:
  - All `pthread_create` references get rewritten to `__wrap_pthread_create`
  - Our shim now defines `__wrap_pthread_create` (no symbol clash)
  - Inside the shim we `dlopen("libc.so")` + `dlsym("pthread_create")` to
    get the real runtime symbol directly, bypassing BOTH the broken static
    stub (libstd's libc.a copy) AND libstd's own pthread_create path
  - `--real_pthread_create` is deliberately NOT used — it would alias the
    same broken stub the wrap exists to avoid

The wrap flag is emitted via `cargo:rustc-link-arg` in build.rs so it
only affects the Android target (the Android-branch of build.rs is the
only place that emits it).

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-09 13:08:41 +04:00
Siavash Sameni
79e876126c fix(android): interpose pthread_create to bypass libstd's broken static stub
Some checks failed
Mirror to GitHub / mirror (push) Failing after 36s
Build Release Binaries / build-amd64 (push) Failing after 3m52s
Builds #7, #8 and #9 all crashed at launch with the same SIGSEGV inside
__init_tcb(bionic_tcb*, pthread_internal_t*)+4 called via pthread_create
from std::sys::thread::unix::Thread::new.

Digging further: the problem is NOT the final linker we pass to cargo.
It's that rustup ships a PRE-COMPILED libstd for aarch64-linux-android
which was built statically against an old NDK libc archive. That archive
has a pthread_create stub which calls a static __init_tcb stub that
assumes libc's static init path has set up the TCB — which never happens
in a .so loaded via dlopen. Bumping minSdk to 26 or forcing the
android26-clang linker (903a07c) doesn't rebuild libstd and therefore
doesn't fix the bundled broken stub.

The legacy wzp-android crate dodged this with a getauxval_fix.c shim that
interposes getauxval via RTLD_NEXT. The same trick works for pthread_create
here: define our own `int pthread_create(...)` in cpp/pthread_shim.c that
forwards to `dlsym(RTLD_NEXT, "pthread_create")` — the real, fully working
version exported from libc.so. The linker processes our static lib before
libstd.rlib, so libstd's unresolved pthread_create reference binds to our
symbol, and the broken libc.a stub inside libstd is never pulled in.

build.rs compiles cpp/pthread_shim.c right after cpp/getauxval_fix.c so
both symbol overrides are in place before any Rust code gets linked.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-09 13:04:18 +04:00
Siavash Sameni
b314138caf feat(android): oboe/AAudio audio backend + runtime mic permission (step 3)
Some checks failed
Mirror to GitHub / mirror (push) Failing after 39s
Build Release Binaries / build-amd64 (push) Failing after 3m39s
This is the big one — the Tauri Android app now has a real audio stack
capable of full-duplex VoIP, reusing the proven C++ Oboe bridge from the
legacy wzp-android crate.

Architecture:
- desktop/src-tauri/cpp/  — copies of oboe_bridge.{h,cpp}, oboe_stub.cpp,
  and getauxval_fix.c from crates/wzp-android/cpp/. build.rs clones
  google/oboe@1.8.1 into OUT_DIR and compiles the bridge + all Oboe
  sources as "oboe_bridge" static lib, linking against shared libc++
  (static would pull broken libc stubs that SIGSEGV in .so libraries).
- src/oboe_audio.rs  — Rust side: an SPSC ring buffer matching the C++
  bridge's AtomicI32 layout, plus OboeHandle::start() which returns
  (capture_ring, playout_ring, owning_handle). The ring exposes the same
  (available / read / write) methods as wzp_client::audio_ring::AudioRing
  so CallEngine treats both backends interchangeably.
- src/engine.rs  — compiled on every platform now. A cfg-switched type
  alias picks wzp_client::audio_ring::AudioRing on desktop and
  crate::oboe_audio::AudioRing on Android. The audio setup block has
  three branches: VPIO/CPAL on macOS, CPAL on Linux/Windows, Oboe on
  Android. Send/recv tasks are identical across platforms.
- src/lib.rs  — removes all the "step 3 not done" Android stubs. The
  engine module is no longer cfg-gated; connect / disconnect / toggle_mic
  / toggle_speaker / get_status are single implementations used by both
  desktop and Android. Identity path resolves via app.path().app_data_dir()
  from the Tauri setup() callback (already wired in step 1).

Runtime mic permission:
- scripts/build-tauri-android.sh now injects RECORD_AUDIO + MODIFY_AUDIO_
  SETTINGS into gen/android/app/src/main/AndroidManifest.xml after init,
  and overwrites MainActivity.kt with a version that calls
  ActivityCompat.requestPermissions in onCreate. This is idempotent:
  every build re-applies the patches so tauri re-init can't regress them.

Cargo.toml:
- cc is now an unconditional build-dep (build.rs runs on the host, so
  target-gating build-deps doesn't work).
- wzp-client is now a dep on every platform. On Android it gets default
  features only (no "audio"/"vpio") so CPAL isn't dragged in — oboe_audio
  provides the capture/playout rings instead.
- tracing-android is added on Android so tracing events flow into logcat.

build.rs also gained embedded git hash (WZP_GIT_HASH) capture, which is
shown under the fingerprint on the home screen — already committed in
7639aaf, reinstated here alongside the Oboe build logic.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-09 12:40:38 +04:00
Siavash Sameni
6b8107504e fix(desktop): tauri capability for android event listeners + persistent debug keystore
Some checks failed
Mirror to GitHub / mirror (push) Failing after 37s
Build Release Binaries / build-amd64 (push) Failing after 3m45s
Two related Android-only papercuts found while testing build #4 on a Pixel 6:

1. Frontend was crashing in the WebView with:
       Tauri/Console: Uncaught (in promise) event.listen not allowed.
       Permissions associated with this command: core:event:allow-listen,
       core:event:default
   The desktop build worked fine because Tauri's default capability set
   covers the desktop side. On Android (and iOS) Tauri 2.x is much stricter
   about ACL — without an explicit capabilities/default.json that lists
   "android" in its platforms, the WebView gets zero permissions. Add a
   default capability granting core:default + the event listener perms
   across all five platforms (linux/macOS/windows/android/iOS).

2. Every fresh docker run produced a new ~/.android/debug.keystore, so
   `adb install -r` of a freshly built APK over an already-installed one
   failed with INSTALL_FAILED_UPDATE_INCOMPATIBLE. Mount a persistent host
   volume at /home/builder/.android in build-tauri-android.sh so the same
   debug keystore is reused across builds and `install -r` keeps working.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-09 12:02:01 +04:00
Siavash Sameni
7639aaf08d feat(desktop): deterministic alias from seed + git hash on home screen + fix EACCES on Android
Some checks failed
Build Release Binaries / build-amd64 (push) Failing after 3m41s
Mirror to GitHub / mirror (push) Failing after 38s
Three home-screen issues from the first Tauri Android APK:

1. Alias was empty (no seed-derived name).
   Port the adjective+noun word lists from the old Kotlin SettingsRepository
   into a `derive_alias()` helper that maps the first 4 bytes of the seed to
   indices in those lists. Same seed → same alias forever, different seeds →
   effectively random aliases — so reinstalls keep the user's identity AND
   the friendly name they're used to.

2. Build identity was invisible — couldn't tell which APK was actually
   installed (this caused us a lot of grief on the Kotlin app).
   build.rs now captures `git rev-parse --short HEAD` and emits it as
   `WZP_GIT_HASH`, exposed via a new `get_app_info` command. The frontend
   stamps `build <hash> • <alias>` under the fingerprint on the home screen.

3. Register on relay failed with `Permission denied (os error 13)`.
   Root cause: I hardcoded `/data/data/com.wzp.phone/files/.wzp` as the
   identity dir, but the Tauri Android package id is `com.wzp.desktop` —
   so the app was trying to write into another app's data directory and
   getting EACCES at the filesystem layer. Fix: resolve the data dir from
   Tauri's `path().app_data_dir()` API in the `setup()` callback and stash
   it in a `OnceLock<PathBuf>`. Works on Android, macOS, Linux, Windows
   without any cfg gymnastics.

Also: `get_app_info` returns the resolved `data_dir` so we can debug
storage issues from the UI (it's set as the build-hash element's title).

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-09 11:55:51 +04:00
Siavash Sameni
e6f77a78a7 feat(desktop): split main.rs into lib.rs for Tauri Mobile (Android/iOS)
Some checks failed
Mirror to GitHub / mirror (push) Failing after 35s
Build Release Binaries / build-amd64 (push) Failing after 3m54s
Tauri 2.x Mobile links the app as a cdylib loaded from a Java Activity, so
all of the Builder/command code has to live in a library crate. Move the
existing logic verbatim into src/lib.rs::run() and reduce src/main.rs to a
two-line desktop entry point that calls into it.

Cargo.toml gets a [lib] section (crate-types: staticlib + cdylib + rlib,
named wzp_desktop_lib) and the wzp-client dependency — which pulls CPAL +
VoiceProcessingIO — is moved behind cfg(not(target_os = "android")) so the
Android cdylib doesn't need an audio backend yet. Engine-backed Tauri
commands (connect/disconnect/toggle_mic/toggle_speaker/get_status) get
Android stubs that return clear "not yet wired" errors. The signaling
commands (register_signal/place_call/answer_call/get_signal_status/
ping_relay/get_identity) are platform-independent and unchanged.

Also: get_identity / register_signal now auto-create the seed if missing
instead of erroring with "connect to a room first", and the identity dir
resolves to /data/data/com.wzp.phone/files/.wzp on Android (proper
app-internal storage) vs \$HOME/.wzp on desktop.

Side note: src/main.rs was previously untracked — desktop builds were
working only because it existed in the local worktree. This commit fixes
that too.

Step 1 of the Android rewrite plan (tauri-mobile scaffold). No audio yet.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-09 11:17:55 +04:00
Siavash Sameni
04a985912a fix: add direct calling Tauri backend commands (register_signal, place_call, answer_call)
Some checks failed
Mirror to GitHub / mirror (push) Failing after 39s
Build Release Binaries / build-amd64 (push) Failing after 3m31s
2026-04-09 06:59:16 +04:00
Siavash Sameni
395a0c557e feat: TX/RX codec badges on desktop call screen
Some checks failed
Mirror to GitHub / mirror (push) Failing after 34s
Build Release Binaries / build-amd64 (push) Failing after 2m1s
Desktop now shows codec badges like Android:
- Green TX badge: e.g. "Opus64k"
- Blue RX badge: e.g. "Opus24k"
Displayed in the stats line below the call controls.

Engine tracks tx_codec (set on encoder init) and rx_codec (updated
from incoming packet headers). Passed through EngineStatus → CallStatus
→ frontend.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-08 12:03:20 +04:00
Siavash Sameni
da593f9510 feat: relay-grouped participant rendering + relay_label in protocol
Some checks failed
Mirror to GitHub / mirror (push) Failing after 36s
Build Release Binaries / build-amd64 (push) Failing after 1m47s
RoomParticipant now has optional relay_label field. Desktop client
groups participants by relay: "This Relay" (green dot) for local,
peer label (blue dot) for federated. Shows all relays in the chain
including intermediate ones.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-08 11:22:05 +04:00
Siavash Sameni
a8c2011445 feat: add Opus 32k/48k/64k studio quality tiers
Some checks failed
Mirror to GitHub / mirror (push) Failing after 36s
Build Release Binaries / build-amd64 (push) Has been cancelled
Adds three new codec IDs (Opus32k=6, Opus48k=7, Opus64k=8) and
corresponding STUDIO_32K, STUDIO_48K, STUDIO_64K quality profiles.
All use 20ms frames with minimal FEC (10%) for maximum quality on
good networks.

Updated across: wire protocol (codec_id.rs), encoder/decoder
(opus_enc/dec.rs), adaptive codec switch (call.rs), CLI
(--profile studio-64k), desktop engine + UI slider (8 quality
levels from Studio 64k green to Codec2 1.2k red).

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-07 18:31:05 +04:00
Siavash Sameni
369347ce54 fix: remove unused FRAME_SAMPLES_20MS constant in desktop engine
Some checks failed
Mirror to GitHub / mirror (push) Failing after 35s
Build Release Binaries / build-amd64 (push) Failing after 1m53s
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-07 17:54:13 +04:00
Siavash Sameni
85c2146760 feat: quality profile selection in desktop settings
Some checks failed
Mirror to GitHub / mirror (push) Failing after 35s
Build Release Binaries / build-amd64 (push) Failing after 1m58s
Adds a Quality dropdown (Auto / Opus 24k / Opus 6k / Codec2 3.2k /
Codec2 1.2k) to both the connect screen and settings panel. The
selected profile is passed through to the engine which configures
the encoder and decoder accordingly.

The desktop engine recv path now auto-switches the decoder codec
when incoming packets use a different codec than expected, enabling
cross-codec interop between clients on different quality settings.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-07 17:44:17 +04:00
Siavash Sameni
f7ccb67b02 fix: desktop ping closes endpoint properly, prevents resource leaks
Some checks failed
Mirror to GitHub / mirror (push) Failing after 39s
Build Release Binaries / build-amd64 (push) Failing after 3m46s
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-07 15:00:32 +04:00
Siavash Sameni
7806d4ec04 feat: identicons, server fingerprints, lock status (TOFU)
Some checks failed
Build Release Binaries / build-amd64 (push) Failing after 3m35s
Identicon generator:
- Deterministic 5x5 symmetric pattern from fingerprint hash
- HSL-derived colors, rendered as inline SVG
- Click any identicon to copy its fingerprint to clipboard
- Used for participants, user identity, and relay servers

Server identity (TOFU — Trust On First Use):
- Ping returns server fingerprint (QUIC peer certificate hash)
- First contact: auto-saved as known fingerprint
- Subsequent pings: compared against known fingerprint
- Lock icons: locked (verified), unlocked (new), warning (changed), red (offline)
- Fingerprint mismatch shows confirmation dialog before connecting

UI updates:
- Participants show identicons instead of letter avatars
- User identity shows identicon + fingerprint on connect screen
- Manage Relays shows identicon per server with lock status
- Relay button shows lock icon instead of colored dot

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-06 13:02:42 +04:00
Siavash Sameni
dddf5d2e2d feat: relay ping with RTT display, fix dead_code warning
Some checks failed
Build Release Binaries / build-amd64 (push) Has been cancelled
- New ping_relay Tauri command: QUIC connect with 3s timeout, returns RTT ms
- Relay status shown next to input field: "42ms" (green) or "offline" (red)
- Auto-pings on app startup and debounced on relay input change
- Fix SyncWrapper dead_code warning with #[allow(dead_code)]

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-06 12:41:28 +04:00
Siavash Sameni
ed272d29f8 feat: fingerprint at startup, relay+room pairs, auto-reconnect, cleanup
Some checks failed
Build Release Binaries / build-amd64 (push) Failing after 3m34s
#7 Fingerprint shown before connecting — new get_identity command reads
   ~/.wzp/identity at startup (generates if missing). Click to copy.

#8 Recent rooms store (relay, room) pairs — clicking a chip fills both
   fields. Settings panel shows relay alongside room name. Migrates
   old string[] format automatically.

#9 Auto-reconnect on unexpected disconnect — exponential backoff
   (1s, 2s, 4s... max 10s), up to 5 attempts. Yellow blinking dot
   shows reconnecting state. Stops if user clicks hangup.

#10 Audio handle cleanup — CPAL handles stored in SyncWrapper (no more
    mem::forget), dropped properly on CallEngine::stop().

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-06 12:15:05 +04:00
Siavash Sameni
21f5b24cbf fix: keep audio handles alive for call duration, fix Send+Sync
Some checks failed
Build Release Binaries / build-amd64 (push) Failing after 3m39s
The VPIO/CPAL audio handles were dropped at the end of start(),
killing the audio unit immediately. Audio I/O stopped working
after the first frame.

- Store audio handle in CallEngine via SyncWrapper
- Drop MutexGuard before returning from status() (Send future)
- Audio streams now live for the entire call duration

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-06 12:00:16 +04:00