18 Commits

Author SHA1 Message Date
Siavash Sameni
01f55caa96 fix(build): escape awk single-quotes inside bash -c heredoc
The awk '{print $5}' and grep 'assets/' inside the single-quoted
Docker bash -c '...' string closed the outer quote early, producing
"unexpected EOF while looking for matching ')'" at runtime.
Use double-quoted awk with escaped $5 instead.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-25 10:17:43 +04:00
Siavash Sameni
0f93a2b745 fix(build): patch unsigned APK directly instead of re-running Gradle
The previous fix re-ran ./gradlew assembleUniversalRelease to include
the missing frontend assets, but BuildTask.kt calls
`cargo tauri android android-studio-script` which requires the full
Tauri CLI build environment — it fails immediately when invoked
standalone.

New approach: inject the dist/ files directly into the unsigned APK
(which is a ZIP file) using `zip -r`. The existing zipalign + apksigner
step re-aligns and signs the result, producing a valid APK. No extra
Gradle invocation needed.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-25 09:56:42 +04:00
Siavash Sameni
2b93bd4b45 fix(build): copy frontendDist to Android assets after cargo tauri build
Tauri CLI 2.10.x silently skips copying the frontendDist (desktop/dist/)
to gen/android/app/src/main/assets/ on Android builds. The WebView then
fails at runtime with "Asset not found: index.html".

After cargo tauri android build, check if index.html landed in the
Android assets folder. If not (the bug path), copy dist/ manually and
re-run ./gradlew assembleUniversalRelease. Gradle is incremental here
(no Java/Kotlin changed) so the extra pass takes < 30s.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-25 09:51:48 +04:00
Siavash Sameni
bc021517c0 feat(scripts): android-build-async.sh — fire-and-forget APK builder
The existing build-tauri-android.sh holds an SSH connection open for
the entire Docker build (~10 min). Running it in the background kills
it when the SSH keepalive times out (~60s of silence during compile).

New script:
- uploads the build script to remote and launches it in a detached
  tmux session so it survives SSH disconnects
- exits immediately (fire-and-forget); build result arrives via ntfy
- --wait flag blocks + downloads APK when done (same as old script)
- same flags as the original: --init, --rust, --no-pull, --debug

Usage:
  ./scripts/android-build-async.sh          # fire and forget
  ./scripts/android-build-async.sh --wait   # block until APK downloaded
  ./scripts/android-build-async.sh --init --wait

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-25 09:39:49 +04:00
Siavash Sameni
739bdaf3ab feat(debug): emit media:room_update and participants call-event from signal task
Pass AppHandle into run_signal_task so it can emit call-debug events
and Tauri events directly. On each RoomUpdate:
- emit connect:media:room_update debug event with participant list
- emit call-event/participants Tauri event for JS-side diagnostics

Helps diagnose whether room join and participant sync is working
independently of audio startup.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-25 09:07:08 +04:00
Siavash Sameni
bc1668ed96 fix(android): run set_audio_mode_communication on Tauri main thread
spawn_blocking uses arbitrary thread-pool threads that don't have the
Android JNI context initialized, causing ndk_context::android_context()
to panic. Switch to run_on_main_thread (where the context is always
valid) via a oneshot channel, with a 2s timeout. Panic is caught and
forwarded as an Err so the debug log captures it rather than crashing.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-25 08:18:18 +04:00
Siavash Sameni
77b036439b fix(android): spawn_blocking + 2s timeout for set_audio_mode_communication
The JNI call into AudioManager.setMode() was running directly on the
tokio async thread. If the Android audio policy service is slow (e.g.
immediately after mic permission grant), this could block the runtime.
Moved to spawn_blocking with a 2s timeout; timeout and panic cases are
logged as connect:audio_mode_timeout / connect:audio_mode_panic debug
events and treated as non-fatal (we continue to audio_start).

Also removes the has_record_audio_permission call from the preflight
debug event — it was a redundant JNI round-trip that added latency and
is now captured separately in the preflight_start event context.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-25 08:08:24 +04:00
Siavash Sameni
0ebc73ab13 fix(android): remove legacy connected event_cb; add preflight_start debug step
The legacy event_cb("connected") call between handshake and audio
preflight was a no-op on the frontend (it enters voice only after the
command resolves) but added noise to failing traces. Replaced with a
connect:connected_event_skipped debug event and added an explicit
connect:android_audio_preflight_start marker so the debug log shows a
clear boundary between handshake completion and audio startup.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-25 08:02:19 +04:00
Siavash Sameni
394987a349 fix(android): 8s Rust timeout on audio_start; always emit connect: debug events
- engine.rs: wrap spawn_blocking(audio_start) in an 8s tokio timeout so
  the connect command fails fast with a clear error if the Oboe HAL
  never returns, instead of blocking the JS 45s timer
- lib.rs: emit_call_debug now always forwards connect: and
  register_signal: steps to the JS overlay regardless of the debug-logs
  toggle — needed because app-data clears reset the toggle to false,
  making join failures invisible on first install
- main.ts: JS timeout bumped to 45s (Rust 8s fires first); timeout
  message now includes last native connect: step so the toast is
  actionable without opening the debug log

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-25 07:49:21 +04:00
Siavash Sameni
2aa6582585 fix(android): call-debug instrumentation for audio startup path
Add emit_call_debug events at every step of the Android connect/audio
path so failures are visible in the Settings debug log without needing
adb logcat:

- connect:handshake_start/done/failed (with timing)
- connect:android_audio_preflight (wzp_native loaded + RECORD_AUDIO
  permission check via new has_record_audio_permission() JNI helper)
- connect:audio_stop_start/done
- connect:audio_mode_start/done/failed
- connect:audio_start_start/failed/panic/done (with oboe error code)
- connect:reuse_endpoint (endpoint reuse diagnostic)

Also adds has_record_audio_permission() to android_audio.rs — used in
the preflight event to confirm the OS has granted mic access before
wzp_oboe_start is called.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-25 07:38:38 +04:00
Siavash Sameni
ca987d547c fix(android): return -6 on Oboe start timeout; fix error toast; add bug report
- oboe_bridge.cpp: return -6 (instead of silent 0) when streams do not
  reach Started within the 2s poll deadline; also clean up streams on
  that path so a retry can succeed
- main.ts: shared connectWithTimeout() so room-join and direct-call
  auto-connect both get the 15s JS timeout; shared errorMessage() so
  Tauri error objects don't show as [object Object] in toasts
- docs/bugs/001-android-join-voice-hang.md: comprehensive bug report
  with root cause chain, evidence, return code table, and next steps

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-25 07:31:55 +04:00
Siavash Sameni
5a13f12334 fix(android): spawn_blocking for audio_start + 15s JS connect timeout
wzp_oboe_start is a sync FFI call that can block the OS thread
indefinitely waiting on the Android audio HAL. Calling it directly
from an async context freezes all tokio tasks including Rust-side
timeouts. Fix: run it via spawn_blocking so tokio stays responsive.

Also add a 15s Promise.race timeout in JS so a frozen audio_start
surfaces as "connect timed out — check audio permissions" instead of
the join button staying stuck in "Connecting…" forever.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-25 07:13:26 +04:00
Siavash Sameni
b0a3b1f18e fix: 10s timeout on handshake CallAnswer; button stays visible during connect
- handshake.rs: add 10s timeout on recv_signal() waiting for CallAnswer —
  previously hung forever if relay didn't respond, making join button
  disappear with no feedback
- main.ts: keep join button visible + show "Connecting…" state instead of
  hiding it before the await; button restores correctly on error

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-25 06:59:57 +04:00
Siavash Sameni
32c07d1b61 fix(ui): show error toast + guard double-tap on join; ntfy relay deploy
- main.ts: add showToast() — surfaces Rust connect errors that were
  previously swallowed silently (key for diagnosing "never joins calls")
- main.ts: connectPending flag prevents double-tap race on Join Voice
  and CallSetup auto-connect; hides button while connect is in-flight
- build-linux-docker.sh: send ntfy notification per-server after each
  relay deploy (shows host + version deployed)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-25 06:49:05 +04:00
Siavash Sameni
5d05b021aa fix(wzp-video): gate shiguredo AV1 crates to macOS only; fix Linux relay build
- Cargo.toml: merge duplicate [target.macos.deps] sections; move
  shiguredo_dav1d/svt_av1/video_toolbox into single block
- lib.rs: dav1d + svt_av1 modules and re-exports guarded by
  cfg(target_os = "macos") instead of cfg(not(android))
- factory.rs: AV1 encoder/decoder paths split into macos (svt-av1/dav1d)
  and linux fallback (NotInitialized); update doc comments and tests
- build-linux-docker.sh: build only wzp-relay + wzp-web (drops
  wzp-client which pulled in shiguredo crates); fix Docker copy step;
  add --deploy flag + deploy_relay(); fix branch auto-detection
- build-tauri-android.sh: default to release build, arm64 only

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-25 06:33:35 +04:00
Siavash Sameni
4ac62d99e0 fix(audit): M1 — add version: u8 to all SignalMessage variants
Convert Hold/Unhold/Mute/Unmute/TransferAck from unit variants to struct
variants with `version: u8` (serde default = 2). Every SignalMessage
variant now carries a version field, enabling future semantic versioning
and clean rejection of deprecated variants during federation routing.

305 tests passing.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-25 06:27:23 +04:00
Siavash Sameni
4ebb2dac2d feat(scripts): add --deploy flag to build-linux-docker.sh
Deploys wzp-relay to both relay servers after building:
- manwe@manwehs:/home/manwe/wzp (tmux session 5)
- manwe@pangolin.manko.yoga:/home/manwe/wzp-linux (tmux session 0)

Captures current relay args from /proc, stops via tmux C-c, restarts
with same args. Also fixes hardcoded branch default to use current git branch.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-25 06:25:32 +04:00
Siavash Sameni
52a6f5e048 fix(audit): address C2, C3, M4, M5 from 2026-05-25 audit
C2: Add EncryptingTransport wrapper — all media I/O now goes through
ChaChaSession encrypt/decrypt before hitting the QUIC datagram path.
cli.rs run_live/run_silence/run_file_mode accept Arc<dyn MediaTransport>
and receive a wrapped transport after the handshake.

C3: Wire VideoScorer::observe() into both plain and trunked forwarding
loops in room.rs. Packets from participants with Abusive verdict are
dropped before forwarding. last_bwe_kbps tracked from quality reports.

M4: Widen FEC repair symbol index from u8 to u16 throughout
(FecEncoder::generate_repair, FecDecoder::add_symbol, all call sites in
call.rs, bench.rs, pipeline.rs, wzp-android). Eliminates theoretical
wrapping when num_source + repair_count > 255.

M5: Track last_encrypt_timestamp in ChaChaSession. debug_assert in
encrypt() that timestamp is non-decreasing across calls (including post-
rekey). complete_rekey() explicitly preserves last_encrypt_timestamp to
prevent accidental timestamp reset regressions.

583 tests passing.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-25 06:20:05 +04:00
28 changed files with 1118 additions and 124 deletions

View File

@@ -796,7 +796,7 @@ async fn run_call(
), ),
seq: rs, seq: rs,
timestamp: t, timestamp: t,
fec_block: ((sym_idx as u16) << 8) | (block_id as u16), fec_block: (sym_idx << 8) | (block_id as u16),
}, },
payload: Bytes::from(repair_data), payload: Bytes::from(repair_data),
quality_report: None, quality_report: None,
@@ -949,7 +949,7 @@ async fn run_call(
let is_repair = pkt.header.is_repair(); let is_repair = pkt.header.is_repair();
let pkt_block = pkt.header.fec_block as u8; let pkt_block = pkt.header.fec_block as u8;
let pkt_symbol = (pkt.header.fec_block >> 8) as u8; let pkt_symbol = pkt.header.fec_block >> 8;
let pkt_is_opus = pkt.header.codec_id.is_opus(); let pkt_is_opus = pkt.header.codec_id.is_opus();
// Phase 2: Opus packets bypass RaptorQ entirely — DRED // Phase 2: Opus packets bypass RaptorQ entirely — DRED

View File

@@ -138,7 +138,7 @@ impl Pipeline {
let is_repair = header.is_repair(); let is_repair = header.is_repair();
if let Err(e) = self.fec_decoder.add_symbol( if let Err(e) = self.fec_decoder.add_symbol(
header.fec_block as u8, header.fec_block as u8,
(header.fec_block >> 8) as u8, header.fec_block >> 8,
is_repair, is_repair,
&packet.payload, &packet.payload,
) { ) {

View File

@@ -170,7 +170,7 @@ pub fn bench_fec_recovery(loss_pct: f32) -> FecResult {
// Collect all symbols: source + repair // Collect all symbols: source + repair
struct Symbol { struct Symbol {
index: u8, index: u16,
is_repair: bool, is_repair: bool,
data: Vec<u8>, data: Vec<u8>,
} }
@@ -180,7 +180,7 @@ pub fn bench_fec_recovery(loss_pct: f32) -> FecResult {
// For add_symbol we need to provide the raw data; the decoder pads internally // For add_symbol we need to provide the raw data; the decoder pads internally
total_source_bytes += sym.len(); total_source_bytes += sym.len();
all_symbols.push(Symbol { all_symbols.push(Symbol {
index: i as u8, index: i as u16,
is_repair: false, is_repair: false,
data: sym.clone(), data: sym.clone(),
}); });

View File

@@ -409,7 +409,7 @@ impl CallEncoder {
fec_ratio: MediaHeader::encode_fec_ratio(self.profile.fec_ratio), fec_ratio: MediaHeader::encode_fec_ratio(self.profile.fec_ratio),
seq: self.seq, seq: self.seq,
timestamp: self.timestamp_ms, timestamp: self.timestamp_ms,
fec_block: u16::from(self.block_id) | (u16::from(sym_idx) << 8), fec_block: u16::from(self.block_id) | (sym_idx << 8),
}, },
payload: Bytes::from(repair_data), payload: Bytes::from(repair_data),
quality_report: None, quality_report: None,
@@ -566,7 +566,7 @@ impl CallDecoder {
if !packet.header.codec_id.is_opus() { if !packet.header.codec_id.is_opus() {
let _ = self.fec_dec.add_symbol( let _ = self.fec_dec.add_symbol(
(packet.header.fec_block & 0xFF) as u8, (packet.header.fec_block & 0xFF) as u8,
(packet.header.fec_block >> 8) as u8, packet.header.fec_block >> 8,
packet.header.is_repair(), packet.header.is_repair(),
&packet.payload, &packet.payload,
); );

View File

@@ -388,7 +388,7 @@ async fn main() -> anyhow::Result<()> {
} }
// Crypto handshake — establishes verified identity + session key // Crypto handshake — establishes verified identity + session key
let _crypto_session = wzp_client::handshake::perform_handshake( let session = wzp_client::handshake::perform_handshake(
&*transport, &*transport,
&seed.0, &seed.0,
None, // alias — desktop client doesn't set one yet None, // alias — desktop client doesn't set one yet
@@ -396,10 +396,15 @@ async fn main() -> anyhow::Result<()> {
.await?; .await?;
info!("crypto handshake complete"); info!("crypto handshake complete");
// Wrap the transport so all media I/O goes through AEAD encryption.
let enc_transport: Arc<dyn wzp_proto::MediaTransport> = Arc::new(
wzp_client::encrypted_transport::EncryptingTransport::new(transport.clone(), session),
);
if cli.live { if cli.live {
#[cfg(feature = "audio")] #[cfg(feature = "audio")]
{ {
return run_live(transport).await; return run_live(enc_transport).await;
} }
#[cfg(not(feature = "audio"))] #[cfg(not(feature = "audio"))]
{ {
@@ -423,19 +428,19 @@ async fn main() -> anyhow::Result<()> {
Ok(()) Ok(())
} else if cli.send_tone_secs.is_some() || cli.send_file.is_some() || cli.record_file.is_some() { } else if cli.send_tone_secs.is_some() || cli.send_file.is_some() || cli.record_file.is_some() {
run_file_mode( run_file_mode(
transport, enc_transport,
cli.send_tone_secs, cli.send_tone_secs,
cli.send_file, cli.send_file,
cli.record_file, cli.record_file,
) )
.await .await
} else { } else {
run_silence(transport).await run_silence(enc_transport).await
} }
} }
/// Send silence frames (connectivity test). /// Send silence frames (connectivity test).
async fn run_silence(transport: Arc<wzp_transport::QuinnTransport>) -> anyhow::Result<()> { async fn run_silence(transport: Arc<dyn wzp_proto::MediaTransport>) -> anyhow::Result<()> {
let config = CallConfig::default(); let config = CallConfig::default();
let mut encoder = CallEncoder::new(&config); let mut encoder = CallEncoder::new(&config);
@@ -485,7 +490,7 @@ async fn run_silence(transport: Arc<wzp_transport::QuinnTransport>) -> anyhow::R
/// File/tone mode: send a test tone or audio file, and/or record received audio. /// File/tone mode: send a test tone or audio file, and/or record received audio.
async fn run_file_mode( async fn run_file_mode(
transport: Arc<wzp_transport::QuinnTransport>, transport: Arc<dyn wzp_proto::MediaTransport>,
send_tone_secs: Option<u32>, send_tone_secs: Option<u32>,
send_file: Option<String>, send_file: Option<String>,
record_file: Option<String>, record_file: Option<String>,
@@ -674,7 +679,7 @@ async fn run_file_mode(
/// Live mode: capture from mic, encode, send; receive, decode, play. /// Live mode: capture from mic, encode, send; receive, decode, play.
#[cfg(feature = "audio")] #[cfg(feature = "audio")]
async fn run_live(transport: Arc<wzp_transport::QuinnTransport>) -> anyhow::Result<()> { async fn run_live(transport: Arc<dyn wzp_proto::MediaTransport>) -> anyhow::Result<()> {
use wzp_client::audio_io::{AudioCapture, AudioPlayback}; use wzp_client::audio_io::{AudioCapture, AudioPlayback};
let capture = AudioCapture::start()?; let capture = AudioCapture::start()?;

View File

@@ -0,0 +1,213 @@
//! `EncryptingTransport` — wraps any `MediaTransport` with a `CryptoSession`.
//!
//! All outbound `send_media` calls encrypt the payload before handing off to
//! the inner transport; all inbound `recv_media` calls decrypt after receiving.
//! Signal, quality, and close are forwarded unchanged.
//!
//! The quality report travels in plaintext so the relay can make QoS decisions
//! without being able to decrypt media content.
use std::sync::{Arc, Mutex};
use async_trait::async_trait;
use bytes::Bytes;
use wzp_proto::{
CryptoSession, MediaHeader, MediaPacket, MediaTransport, PathQuality, SignalMessage,
TransportError,
};
/// Wraps a `MediaTransport` and applies AEAD encryption/decryption to media payloads.
pub struct EncryptingTransport {
inner: Arc<dyn MediaTransport>,
session: Mutex<Box<dyn CryptoSession>>,
}
impl EncryptingTransport {
pub fn new(inner: Arc<dyn MediaTransport>, session: Box<dyn CryptoSession>) -> Self {
Self {
inner,
session: Mutex::new(session),
}
}
}
#[async_trait]
impl MediaTransport for EncryptingTransport {
async fn send_media(&self, packet: &MediaPacket) -> Result<(), TransportError> {
let mut header_bytes = Vec::with_capacity(MediaHeader::WIRE_SIZE);
packet.header.write_to(&mut header_bytes);
let mut ciphertext = Vec::new();
self.session
.lock()
.unwrap()
.encrypt(&header_bytes, &packet.payload, &mut ciphertext)
.map_err(|e| TransportError::Internal(format!("encrypt: {e}")))?;
let encrypted = MediaPacket {
header: packet.header,
payload: Bytes::from(ciphertext),
quality_report: packet.quality_report.clone(),
};
self.inner.send_media(&encrypted).await
}
async fn recv_media(&self) -> Result<Option<MediaPacket>, TransportError> {
let packet = match self.inner.recv_media().await? {
Some(p) => p,
None => return Ok(None),
};
let mut header_bytes = Vec::with_capacity(MediaHeader::WIRE_SIZE);
packet.header.write_to(&mut header_bytes);
let mut plaintext = Vec::new();
self.session
.lock()
.unwrap()
.decrypt(&header_bytes, &packet.payload, &mut plaintext)
.map_err(|e| TransportError::Internal(format!("decrypt: {e}")))?;
Ok(Some(MediaPacket {
header: packet.header,
payload: Bytes::from(plaintext),
quality_report: packet.quality_report,
}))
}
async fn send_signal(&self, msg: &SignalMessage) -> Result<(), TransportError> {
self.inner.send_signal(msg).await
}
async fn recv_signal(&self) -> Result<Option<SignalMessage>, TransportError> {
self.inner.recv_signal().await
}
fn path_quality(&self) -> PathQuality {
self.inner.path_quality()
}
async fn close(&self) -> Result<(), TransportError> {
self.inner.close().await
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::sync::Mutex as StdMutex;
use wzp_crypto::ChaChaSession;
use wzp_proto::{CodecId, MediaType};
struct LoopbackTransport {
sent: StdMutex<Vec<MediaPacket>>,
}
impl LoopbackTransport {
fn new() -> Arc<Self> {
Arc::new(Self {
sent: StdMutex::new(Vec::new()),
})
}
fn take_sent(&self) -> Vec<MediaPacket> {
self.sent.lock().unwrap().drain(..).collect()
}
}
#[async_trait]
impl MediaTransport for LoopbackTransport {
async fn send_media(&self, packet: &MediaPacket) -> Result<(), TransportError> {
self.sent.lock().unwrap().push(packet.clone());
Ok(())
}
async fn recv_media(&self) -> Result<Option<MediaPacket>, TransportError> {
Ok(None)
}
async fn send_signal(&self, _msg: &SignalMessage) -> Result<(), TransportError> {
Ok(())
}
async fn recv_signal(&self) -> Result<Option<SignalMessage>, TransportError> {
Ok(None)
}
fn path_quality(&self) -> PathQuality {
PathQuality::default()
}
async fn close(&self) -> Result<(), TransportError> {
Ok(())
}
}
fn make_header(seq: u32) -> MediaHeader {
MediaHeader {
version: 2,
flags: 0,
media_type: MediaType::Audio,
codec_id: CodecId::Opus24k,
stream_id: 0,
fec_ratio: 0,
seq,
timestamp: seq * 20,
fec_block: 0,
}
}
#[tokio::test]
async fn payload_is_encrypted_on_wire() {
let key = [0x42u8; 32];
let session: Box<dyn CryptoSession> = Box::new(ChaChaSession::new(key));
let loopback = LoopbackTransport::new();
let enc = EncryptingTransport::new(loopback.clone(), session);
let header = make_header(1);
let plaintext = b"secret audio frame";
let pkt = MediaPacket {
header,
payload: Bytes::from_static(plaintext),
quality_report: None,
};
enc.send_media(&pkt).await.unwrap();
let sent = loopback.take_sent();
assert_eq!(sent.len(), 1);
assert_eq!(sent[0].header, header, "header must be preserved");
assert_ne!(
sent[0].payload.as_ref(),
plaintext.as_ref(),
"plaintext must not appear on wire"
);
// Ciphertext is longer by exactly the AEAD tag (16 bytes)
assert_eq!(sent[0].payload.len(), plaintext.len() + 16);
}
#[tokio::test]
async fn encrypt_then_decrypt_roundtrip() {
let key = [0x42u8; 32];
let send_session: Box<dyn CryptoSession> = Box::new(ChaChaSession::new(key));
let mut recv_session = ChaChaSession::new(key);
let loopback = LoopbackTransport::new();
let enc = EncryptingTransport::new(loopback.clone(), send_session);
let header = make_header(5);
let plaintext = b"hello encrypted world";
let pkt = MediaPacket {
header,
payload: Bytes::from_static(plaintext),
quality_report: None,
};
enc.send_media(&pkt).await.unwrap();
let sent = loopback.take_sent();
let wire_pkt = &sent[0];
let mut header_bytes = Vec::new();
header.write_to(&mut header_bytes);
let mut decrypted = Vec::new();
recv_session
.decrypt(&header_bytes, &wire_pkt.payload, &mut decrypted)
.expect("decrypt should succeed with matching key");
assert_eq!(&decrypted[..], plaintext);
}
}

View File

@@ -99,12 +99,12 @@ pub fn signal_to_call_type(signal: &SignalMessage) -> CallSignalType {
SignalMessage::LossRecoveryUpdate { .. } => CallSignalType::Offer, // reuse (telemetry) SignalMessage::LossRecoveryUpdate { .. } => CallSignalType::Offer, // reuse (telemetry)
SignalMessage::Ping { .. } | SignalMessage::Pong { .. } => CallSignalType::Offer, SignalMessage::Ping { .. } | SignalMessage::Pong { .. } => CallSignalType::Offer,
SignalMessage::AuthToken { .. } => CallSignalType::Offer, SignalMessage::AuthToken { .. } => CallSignalType::Offer,
SignalMessage::Hold => CallSignalType::Hold, SignalMessage::Hold { .. } => CallSignalType::Hold,
SignalMessage::Unhold => CallSignalType::Unhold, SignalMessage::Unhold { .. } => CallSignalType::Unhold,
SignalMessage::Mute => CallSignalType::Mute, SignalMessage::Mute { .. } => CallSignalType::Mute,
SignalMessage::Unmute => CallSignalType::Unmute, SignalMessage::Unmute { .. } => CallSignalType::Unmute,
SignalMessage::Transfer { .. } => CallSignalType::Transfer, SignalMessage::Transfer { .. } => CallSignalType::Transfer,
SignalMessage::TransferAck => CallSignalType::Offer, // reuse SignalMessage::TransferAck { .. } => CallSignalType::Offer, // reuse
SignalMessage::PresenceUpdate { .. } => CallSignalType::Offer, // reuse SignalMessage::PresenceUpdate { .. } => CallSignalType::Offer, // reuse
SignalMessage::RouteQuery { .. } => CallSignalType::Offer, // reuse SignalMessage::RouteQuery { .. } => CallSignalType::Offer, // reuse
SignalMessage::TransportFeedback { .. } => CallSignalType::Offer, // reuse (BWE) SignalMessage::TransportFeedback { .. } => CallSignalType::Offer, // reuse (BWE)
@@ -199,19 +199,19 @@ mod tests {
)); ));
assert!(matches!( assert!(matches!(
signal_to_call_type(&SignalMessage::Hold), signal_to_call_type(&SignalMessage::Hold { version: default_signal_version() }),
CallSignalType::Hold CallSignalType::Hold
)); ));
assert!(matches!( assert!(matches!(
signal_to_call_type(&SignalMessage::Unhold), signal_to_call_type(&SignalMessage::Unhold { version: default_signal_version() }),
CallSignalType::Unhold CallSignalType::Unhold
)); ));
assert!(matches!( assert!(matches!(
signal_to_call_type(&SignalMessage::Mute), signal_to_call_type(&SignalMessage::Mute { version: default_signal_version() }),
CallSignalType::Mute CallSignalType::Mute
)); ));
assert!(matches!( assert!(matches!(
signal_to_call_type(&SignalMessage::Unmute), signal_to_call_type(&SignalMessage::Unmute { version: default_signal_version() }),
CallSignalType::Unmute CallSignalType::Unmute
)); ));

View File

@@ -101,10 +101,13 @@ pub async fn perform_handshake(
.await .await
.map_err(HandshakeError::Transport)?; .map_err(HandshakeError::Transport)?;
// 5. Wait for CallAnswer // 5. Wait for CallAnswer — 10s timeout guards against relay not responding.
let answer = transport let answer = tokio::time::timeout(
.recv_signal() std::time::Duration::from_secs(10),
transport.recv_signal(),
)
.await .await
.map_err(|_| HandshakeError::Transport(wzp_proto::TransportError::Timeout { ms: 10_000 }))?
.map_err(HandshakeError::Transport)? .map_err(HandshakeError::Transport)?
.ok_or(HandshakeError::ConnectionClosed)?; .ok_or(HandshakeError::ConnectionClosed)?;

View File

@@ -29,6 +29,7 @@ pub mod audio_linux_aec;
pub mod bench; pub mod bench;
pub mod birthday; pub mod birthday;
pub mod call; pub mod call;
pub mod encrypted_transport;
pub mod drift_test; pub mod drift_test;
pub mod dual_path; pub mod dual_path;
pub mod echo_test; pub mod echo_test;

View File

@@ -33,6 +33,8 @@ pub struct ChaChaSession {
sas_code: Option<u32>, sas_code: Option<u32>,
/// Per-stream anti-replay windows, keyed by (stream_id, media_type). /// Per-stream anti-replay windows, keyed by (stream_id, media_type).
anti_replay: HashMap<(u8, MediaType), AntiReplayWindow>, anti_replay: HashMap<(u8, MediaType), AntiReplayWindow>,
/// Last timestamp seen in encrypt() — used to assert monotonicity across rekeys.
last_encrypt_timestamp: Option<u32>,
} }
impl ChaChaSession { impl ChaChaSession {
@@ -55,6 +57,7 @@ impl ChaChaSession {
pending_rekey_secret: None, pending_rekey_secret: None,
sas_code: None, sas_code: None,
anti_replay: HashMap::new(), anti_replay: HashMap::new(),
last_encrypt_timestamp: None,
} }
} }
@@ -122,6 +125,18 @@ impl CryptoSession for ChaChaSession {
out.extend_from_slice(&ciphertext); out.extend_from_slice(&ciphertext);
self.send_seq = self.send_seq.wrapping_add(1); // packet counter for rekey trigger only self.send_seq = self.send_seq.wrapping_add(1); // packet counter for rekey trigger only
// M5: assert timestamp_ms is non-decreasing across calls (including post-rekey).
// Timestamps are u32 and wrap at 2^32 ms (~49 days); allow wrapping.
debug_assert!(
self.last_encrypt_timestamp
.map_or(true, |last| header.timestamp.wrapping_sub(last) < u32::MAX / 2),
"encrypt: timestamp must not decrease (last={:?}, now={})",
self.last_encrypt_timestamp,
header.timestamp,
);
self.last_encrypt_timestamp = Some(header.timestamp);
Ok(()) Ok(())
} }
@@ -189,7 +204,9 @@ impl CryptoSession for ChaChaSession {
.perform_rekey(peer_ephemeral_pub, secret, total_packets); .perform_rekey(peer_ephemeral_pub, secret, total_packets);
self.install_key(new_key); self.install_key(new_key);
// Reset sequence counters after rekey for nonce uniqueness // Reset sequence counters after rekey for nonce uniqueness.
// last_encrypt_timestamp is intentionally NOT reset — spec requires
// timestamp_ms to be monotonic across rekeys.
self.send_seq = 0; self.send_seq = 0;
self.recv_seq = 0; self.recv_seq = 0;

View File

@@ -73,7 +73,7 @@ impl FecDecoder for RaptorQFecDecoder {
fn add_symbol( fn add_symbol(
&mut self, &mut self,
block_id: u8, block_id: u8,
symbol_index: u8, symbol_index: u16,
_is_repair: bool, _is_repair: bool,
data: &[u8], data: &[u8],
) -> Result<(), FecError> { ) -> Result<(), FecError> {
@@ -195,7 +195,7 @@ mod tests {
// Feed all source symbols (using the length-prefixed padded data). // Feed all source symbols (using the length-prefixed padded data).
for (i, pkt) in source_pkts.iter().enumerate() { for (i, pkt) in source_pkts.iter().enumerate() {
decoder.add_symbol(0, i as u8, false, pkt.data()).unwrap(); decoder.add_symbol(0, i as u16, false, pkt.data()).unwrap();
} }
let result = decoder.try_decode(0).unwrap(); let result = decoder.try_decode(0).unwrap();
@@ -293,10 +293,10 @@ mod tests {
// Interleave symbols from block 0 and block 1 // Interleave symbols from block 0 and block 1
for i in 0..FRAMES_PER_BLOCK { for i in 0..FRAMES_PER_BLOCK {
decoder decoder
.add_symbol(0, i as u8, false, pkts_a[i].data()) .add_symbol(0, i as u16, false, pkts_a[i].data())
.unwrap(); .unwrap();
decoder decoder
.add_symbol(1, i as u8, false, pkts_b[i].data()) .add_symbol(1, i as u16, false, pkts_b[i].data())
.unwrap(); .unwrap();
} }

View File

@@ -108,7 +108,7 @@ impl FecEncoder for RaptorQFecEncoder {
Ok(()) Ok(())
} }
fn generate_repair(&mut self, ratio: f32) -> Result<Vec<(u8, Vec<u8>)>, FecError> { fn generate_repair(&mut self, ratio: f32) -> Result<Vec<(u16, Vec<u8>)>, FecError> {
if self.source_symbols.is_empty() { if self.source_symbols.is_empty() {
return Ok(vec![]); return Ok(vec![]);
} }
@@ -133,11 +133,11 @@ impl FecEncoder for RaptorQFecEncoder {
// Generate repair packets starting from offset 0 (ESIs begin at num_source). // Generate repair packets starting from offset 0 (ESIs begin at num_source).
let repair_packets: Vec<EncodingPacket> = encoder.repair_packets(0, num_repair); let repair_packets: Vec<EncodingPacket> = encoder.repair_packets(0, num_repair);
let result: Vec<(u8, Vec<u8>)> = repair_packets let result: Vec<(u16, Vec<u8>)> = repair_packets
.into_iter() .into_iter()
.enumerate() .enumerate()
.map(|(i, pkt): (usize, EncodingPacket)| { .map(|(i, pkt): (usize, EncodingPacket)| {
let idx = (num_source as u8).wrapping_add(i as u8); let idx = (num_source as u16).wrapping_add(i as u16);
(idx, pkt.data().to_vec()) (idx, pkt.data().to_vec())
}) })
.collect(); .collect();

View File

@@ -404,12 +404,14 @@ int wzp_oboe_start(const WzpOboeConfig* config, const WzpOboeRings* rings) {
{ {
auto deadline = std::chrono::steady_clock::now() + std::chrono::milliseconds(2000); auto deadline = std::chrono::steady_clock::now() + std::chrono::milliseconds(2000);
int poll_count = 0; int poll_count = 0;
bool streams_started = false;
while (std::chrono::steady_clock::now() < deadline) { while (std::chrono::steady_clock::now() < deadline) {
auto cap_state = g_capture_stream->getState(); auto cap_state = g_capture_stream->getState();
auto play_state = g_playout_stream->getState(); auto play_state = g_playout_stream->getState();
if (cap_state == oboe::StreamState::Started && if (cap_state == oboe::StreamState::Started &&
play_state == oboe::StreamState::Started) { play_state == oboe::StreamState::Started) {
LOGI("both streams Started after %d polls", poll_count); LOGI("both streams Started after %d polls", poll_count);
streams_started = true;
break; break;
} }
poll_count++; poll_count++;
@@ -420,6 +422,18 @@ int wzp_oboe_start(const WzpOboeConfig* config, const WzpOboeRings* rings) {
(int)g_capture_stream->getState(), (int)g_capture_stream->getState(),
(int)g_playout_stream->getState(), (int)g_playout_stream->getState(),
poll_count); poll_count);
if (!streams_started) {
LOGE("Timed out waiting for Oboe streams to reach Started state");
g_running.store(false, std::memory_order_release);
g_rings_valid.store(false, std::memory_order_release);
g_capture_stream->requestStop();
g_playout_stream->requestStop();
g_capture_stream->close();
g_playout_stream->close();
g_capture_stream.reset();
g_playout_stream.reset();
return -6;
}
} }
LOGI("Oboe started: sr=%d burst=%d ch=%d", LOGI("Oboe started: sr=%d burst=%d ch=%d",

View File

@@ -669,13 +669,25 @@ pub enum SignalMessage {
}, },
/// Put the call on hold (stop sending media, keep session alive). /// Put the call on hold (stop sending media, keep session alive).
Hold, Hold {
#[serde(default = "default_signal_version")]
version: u8,
},
/// Resume a held call. /// Resume a held call.
Unhold, Unhold {
#[serde(default = "default_signal_version")]
version: u8,
},
/// Mute request from the remote side (server-initiated mute, like IAX2 QUELCH). /// Mute request from the remote side (server-initiated mute, like IAX2 QUELCH).
Mute, Mute {
#[serde(default = "default_signal_version")]
version: u8,
},
/// Unmute request from the remote side (like IAX2 UNQUELCH). /// Unmute request from the remote side (like IAX2 UNQUELCH).
Unmute, Unmute {
#[serde(default = "default_signal_version")]
version: u8,
},
/// Transfer the call to another peer. /// Transfer the call to another peer.
Transfer { Transfer {
#[serde(default = "default_signal_version")] #[serde(default = "default_signal_version")]
@@ -685,7 +697,10 @@ pub enum SignalMessage {
relay_addr: Option<String>, relay_addr: Option<String>,
}, },
/// Acknowledge a transfer request. /// Acknowledge a transfer request.
TransferAck, TransferAck {
#[serde(default = "default_signal_version")]
version: u8,
},
/// Presence update from a peer relay (gossip protocol). /// Presence update from a peer relay (gossip protocol).
/// Sent periodically over probe connections to share which fingerprints /// Sent periodically over probe connections to share which fingerprints
@@ -1729,7 +1744,7 @@ mod tests {
version: default_signal_version(), version: default_signal_version(),
timestamp_ms: 12345, timestamp_ms: 12345,
}, },
SignalMessage::Hold, SignalMessage::Hold { version: default_signal_version() },
SignalMessage::Hangup { SignalMessage::Hangup {
version: default_signal_version(), version: default_signal_version(),
reason: HangupReason::Normal, reason: HangupReason::Normal,
@@ -1750,28 +1765,28 @@ mod tests {
#[test] #[test]
fn hold_unhold_serialize() { fn hold_unhold_serialize() {
let hold = SignalMessage::Hold; let hold = SignalMessage::Hold { version: default_signal_version() };
let json = serde_json::to_string(&hold).unwrap(); let json = serde_json::to_string(&hold).unwrap();
let decoded: SignalMessage = serde_json::from_str(&json).unwrap(); let decoded: SignalMessage = serde_json::from_str(&json).unwrap();
assert!(matches!(decoded, SignalMessage::Hold)); assert!(matches!(decoded, SignalMessage::Hold { .. }));
let unhold = SignalMessage::Unhold; let unhold = SignalMessage::Unhold { version: default_signal_version() };
let json = serde_json::to_string(&unhold).unwrap(); let json = serde_json::to_string(&unhold).unwrap();
let decoded: SignalMessage = serde_json::from_str(&json).unwrap(); let decoded: SignalMessage = serde_json::from_str(&json).unwrap();
assert!(matches!(decoded, SignalMessage::Unhold)); assert!(matches!(decoded, SignalMessage::Unhold { .. }));
} }
#[test] #[test]
fn mute_unmute_serialize() { fn mute_unmute_serialize() {
let mute = SignalMessage::Mute; let mute = SignalMessage::Mute { version: default_signal_version() };
let json = serde_json::to_string(&mute).unwrap(); let json = serde_json::to_string(&mute).unwrap();
let decoded: SignalMessage = serde_json::from_str(&json).unwrap(); let decoded: SignalMessage = serde_json::from_str(&json).unwrap();
assert!(matches!(decoded, SignalMessage::Mute)); assert!(matches!(decoded, SignalMessage::Mute { .. }));
let unmute = SignalMessage::Unmute; let unmute = SignalMessage::Unmute { version: default_signal_version() };
let json = serde_json::to_string(&unmute).unwrap(); let json = serde_json::to_string(&unmute).unwrap();
let decoded: SignalMessage = serde_json::from_str(&json).unwrap(); let decoded: SignalMessage = serde_json::from_str(&json).unwrap();
assert!(matches!(decoded, SignalMessage::Unmute)); assert!(matches!(decoded, SignalMessage::Unmute { .. }));
} }
#[test] #[test]
@@ -1818,10 +1833,10 @@ mod tests {
#[test] #[test]
fn transfer_ack_serialize() { fn transfer_ack_serialize() {
let ack = SignalMessage::TransferAck; let ack = SignalMessage::TransferAck { version: default_signal_version() };
let json = serde_json::to_string(&ack).unwrap(); let json = serde_json::to_string(&ack).unwrap();
let decoded: SignalMessage = serde_json::from_str(&json).unwrap(); let decoded: SignalMessage = serde_json::from_str(&json).unwrap();
assert!(matches!(decoded, SignalMessage::TransferAck)); assert!(matches!(decoded, SignalMessage::TransferAck { .. }));
} }
#[test] #[test]

View File

@@ -81,7 +81,7 @@ pub trait FecEncoder: Send + Sync {
/// ///
/// `ratio` is the repair overhead (e.g., 0.5 = 50% more symbols than source). /// `ratio` is the repair overhead (e.g., 0.5 = 50% more symbols than source).
/// Returns `(fec_symbol_index, repair_data)` pairs. /// Returns `(fec_symbol_index, repair_data)` pairs.
fn generate_repair(&mut self, ratio: f32) -> Result<Vec<(u8, Vec<u8>)>, FecError>; fn generate_repair(&mut self, ratio: f32) -> Result<Vec<(u16, Vec<u8>)>, FecError>;
/// Finalize the current block and start a new one. /// Finalize the current block and start a new one.
/// Returns the block ID of the finalized block. /// Returns the block ID of the finalized block.
@@ -100,7 +100,7 @@ pub trait FecDecoder: Send + Sync {
fn add_symbol( fn add_symbol(
&mut self, &mut self,
block_id: u8, block_id: u8,
symbol_index: u8, symbol_index: u16,
is_repair: bool, is_repair: bool,
data: &[u8], data: &[u8],
) -> Result<(), FecError>; ) -> Result<(), FecError>;

View File

@@ -111,7 +111,7 @@ impl RelayPipeline {
let header = &packet.header; let header = &packet.header;
let _ = self.fec_decoder.add_symbol( let _ = self.fec_decoder.add_symbol(
(header.fec_block & 0xFF) as u8, (header.fec_block & 0xFF) as u8,
(header.fec_block >> 8) as u8, header.fec_block >> 8,
header.is_repair(), header.is_repair(),
&packet.payload, &packet.payload,
); );

View File

@@ -21,6 +21,8 @@ use wzp_proto::{MediaTransport, default_signal_version};
use crate::conformance::ConformanceMeter; use crate::conformance::ConformanceMeter;
use crate::metrics::RelayMetrics; use crate::metrics::RelayMetrics;
use crate::trunk::TrunkBatcher; use crate::trunk::TrunkBatcher;
use crate::verdict::Verdict;
use crate::video_scorer::VideoScorer;
/// Debug tap: logs packet metadata for matching rooms. /// Debug tap: logs packet metadata for matching rooms.
#[derive(Clone)] #[derive(Clone)]
@@ -1194,6 +1196,9 @@ async fn run_participant_plain(
None None
}; };
let mut video_scorer = VideoScorer::new();
let mut last_bwe_kbps: Option<u32> = None;
info!( info!(
room = %room_name, room = %room_name,
participant = participant_id, participant = participant_id,
@@ -1261,10 +1266,20 @@ async fn run_participant_plain(
); );
} }
// TODO(T6.2-follow-up): feed video packets to VideoScorer here. // Feed video packets to VideoScorer; drop if verdict is Abusive.
// if pkt.header.media_type == MediaType::Video { if pkt.header.media_type == wzp_proto::MediaType::Video {
// video_scorer.observe(&pkt.header, pkt.payload.len(), now, bwe_kbps); let now = std::time::Instant::now();
// } video_scorer.observe(&pkt.header, pkt.payload.len(), now, last_bwe_kbps);
if let Some(Verdict::Abusive) = video_scorer.verdict() {
warn!(
room = %room_name,
participant = participant_id,
seq = pkt.header.seq,
"VideoScorer: Abusive verdict — dropping packet"
);
continue;
}
}
// 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 {
@@ -1274,6 +1289,7 @@ async fn run_participant_plain(
// Update receiver state from this participant's quality report (if present). // Update receiver state from this participant's quality report (if present).
if let Some(ref report) = pkt.quality_report { if let Some(ref report) = pkt.quality_report {
let bwe_kbps = report.bitrate_cap_kbps as u32; let bwe_kbps = report.bitrate_cap_kbps as u32;
last_bwe_kbps = Some(bwe_kbps);
room_mgr.update_receiver_state(&room_name, participant_id, bwe_kbps, report.loss_pct); room_mgr.update_receiver_state(&room_name, participant_id, bwe_kbps, report.loss_pct);
} }
@@ -1454,6 +1470,8 @@ async fn run_participant_trunked(
let mut last_log_instant = std::time::Instant::now(); let mut last_log_instant = std::time::Instant::now();
let mut conformance = let mut conformance =
ConformanceMeter::with_token_bucket(crate::conformance::TokenBucket::for_audio_session()); ConformanceMeter::with_token_bucket(crate::conformance::TokenBucket::for_audio_session());
let mut video_scorer_trunked = VideoScorer::new();
let mut last_bwe_kbps_trunked: Option<u32> = None;
info!( info!(
room = %room_name, room = %room_name,
@@ -1533,9 +1551,25 @@ async fn run_participant_trunked(
); );
} }
// Feed video packets to VideoScorer; drop if verdict is Abusive.
if pkt.header.media_type == wzp_proto::MediaType::Video {
let now = std::time::Instant::now();
video_scorer_trunked.observe(&pkt.header, pkt.payload.len(), now, last_bwe_kbps_trunked);
if let Some(Verdict::Abusive) = video_scorer_trunked.verdict() {
warn!(
room = %room_name,
participant = participant_id,
seq = pkt.header.seq,
"VideoScorer: Abusive verdict — dropping packet (trunked)"
);
continue;
}
}
// Update receiver state from this participant's quality report. // Update receiver state from this participant's quality report.
if let Some(ref report) = pkt.quality_report { if let Some(ref report) = pkt.quality_report {
let bwe_kbps = report.bitrate_cap_kbps as u32; let bwe_kbps = report.bitrate_cap_kbps as u32;
last_bwe_kbps_trunked = Some(bwe_kbps);
room_mgr.update_receiver_state(&room_name, participant_id, bwe_kbps, report.loss_pct); room_mgr.update_receiver_state(&room_name, participant_id, bwe_kbps, report.loss_pct);
} }

View File

@@ -10,13 +10,12 @@ bytes = { workspace = true }
tracing = { workspace = true } tracing = { workspace = true }
wzp-proto = { path = "../wzp-proto" } wzp-proto = { path = "../wzp-proto" }
# AV1 SW codecs do not support Android target (build.rs panics on # AV1 SW codecs: shiguredo crates download prebuilt binaries at build time.
# aarch64-linux-android). Android uses MediaCodec for AV1 instead. # Prebuilts are available for macOS only; Android uses MediaCodec; Linux will
[target.'cfg(not(target_os = "android"))'.dependencies] # use system/vendored libs when that path is wired up (TODO).
[target.'cfg(target_os = "macos")'.dependencies]
shiguredo_dav1d = "2026.1.0" shiguredo_dav1d = "2026.1.0"
shiguredo_svt_av1 = "2026.1.0" shiguredo_svt_av1 = "2026.1.0"
[target.'cfg(target_os = "macos")'.dependencies]
shiguredo_video_toolbox = "2026.1" shiguredo_video_toolbox = "2026.1"
[target.'cfg(target_os = "android")'.dependencies] [target.'cfg(target_os = "android")'.dependencies]

View File

@@ -11,7 +11,7 @@ use crate::encoder::{VideoEncoder, VideoError};
/// **Encoder dispatch:** /// **Encoder dispatch:**
/// - `H264Baseline` → `VideoToolboxEncoder` (macOS) / `MediaCodecEncoder` (Android) /// - `H264Baseline` → `VideoToolboxEncoder` (macOS) / `MediaCodecEncoder` (Android)
/// - `H265Main` → `VideoToolboxHevcEncoder` (macOS) / `MediaCodecHevcEncoder` (Android) /// - `H265Main` → `VideoToolboxHevcEncoder` (macOS) / `MediaCodecHevcEncoder` (Android)
/// - `Av1Main` → `SvtAv1Encoder` (all platforms — universal SW fallback) /// - `Av1Main` → `SvtAv1Encoder` (macOS only — SW fallback)
/// ///
/// Non-video codecs return [`VideoError::InvalidInput`]. /// Non-video codecs return [`VideoError::InvalidInput`].
pub fn create_video_encoder( pub fn create_video_encoder(
@@ -78,10 +78,15 @@ pub fn create_video_encoder(
#[allow(clippy::needless_return)] #[allow(clippy::needless_return)]
return Err(VideoError::NotInitialized); return Err(VideoError::NotInitialized);
} }
#[cfg(not(target_os = "android"))] #[cfg(target_os = "macos")]
{ {
Ok(Box::new(crate::svt_av1::SvtAv1Encoder::new(width, height)?)) Ok(Box::new(crate::svt_av1::SvtAv1Encoder::new(width, height)?))
} }
#[cfg(not(any(target_os = "macos", target_os = "android")))]
{
let _ = (width, height);
Err(VideoError::NotInitialized)
}
} }
_ => Err(VideoError::InvalidInput("not a video codec".into())), _ => Err(VideoError::InvalidInput("not a video codec".into())),
} }
@@ -92,7 +97,7 @@ pub fn create_video_encoder(
/// **Decoder dispatch:** /// **Decoder dispatch:**
/// - `H264Baseline` → `VideoToolboxDecoder` (macOS) / `MediaCodecDecoder` (Android) /// - `H264Baseline` → `VideoToolboxDecoder` (macOS) / `MediaCodecDecoder` (Android)
/// - `H265Main` → `VideoToolboxHevcDecoder` (macOS) / `MediaCodecHevcDecoder` (Android) /// - `H265Main` → `VideoToolboxHevcDecoder` (macOS) / `MediaCodecHevcDecoder` (Android)
/// - `Av1Main` → `VideoToolboxAv1Decoder` (macOS M3+) → `Dav1dDecoder` (fallback, all platforms) /// - `Av1Main` → `VideoToolboxAv1Decoder` (macOS M3+) → `Dav1dDecoder` (macOS SW fallback)
/// ///
/// Non-video codecs return [`VideoError::InvalidInput`]. /// Non-video codecs return [`VideoError::InvalidInput`].
pub fn create_video_decoder( pub fn create_video_decoder(
@@ -154,10 +159,15 @@ pub fn create_video_decoder(
return crate::mediacodec::MediaCodecAv1Decoder::new(width, height) return crate::mediacodec::MediaCodecAv1Decoder::new(width, height)
.map(|d| Box::new(d) as Box<dyn VideoDecoder>); .map(|d| Box::new(d) as Box<dyn VideoDecoder>);
} }
#[cfg(not(target_os = "android"))] #[cfg(target_os = "macos")]
{ {
Ok(Box::new(crate::dav1d::Dav1dDecoder::new()?)) Ok(Box::new(crate::dav1d::Dav1dDecoder::new()?))
} }
#[cfg(not(any(target_os = "macos", target_os = "android")))]
{
let _ = (width, height);
Err(VideoError::NotInitialized)
}
} }
_ => Err(VideoError::InvalidInput("not a video codec".into())), _ => Err(VideoError::InvalidInput("not a video codec".into())),
} }
@@ -170,30 +180,24 @@ mod tests {
#[test] #[test]
fn av1_encoder_factory_creates_svt_av1() { fn av1_encoder_factory_creates_svt_av1() {
let enc = create_video_encoder(CodecId::Av1Main, 640, 480, 2_000_000); let enc = create_video_encoder(CodecId::Av1Main, 640, 480, 2_000_000);
#[cfg(target_os = "android")] #[cfg(target_os = "macos")]
assert!(enc.is_ok(), "AV1 encoder factory should succeed on macOS");
#[cfg(not(target_os = "macos"))]
assert!( assert!(
matches!(enc, Err(VideoError::NotInitialized)), matches!(enc, Err(VideoError::NotInitialized)),
"AV1 SW encoder is unavailable on Android (no shiguredo_svt_av1)" "AV1 SW encoder is unavailable on Android/Linux (no shiguredo_svt_av1)"
);
#[cfg(not(target_os = "android"))]
assert!(
enc.is_ok(),
"AV1 encoder factory should succeed on non-Android platforms"
); );
} }
#[test] #[test]
fn av1_decoder_factory_creates_decoder() { fn av1_decoder_factory_creates_decoder() {
let dec = create_video_decoder(CodecId::Av1Main, 640, 480); let dec = create_video_decoder(CodecId::Av1Main, 640, 480);
#[cfg(target_os = "android")] #[cfg(target_os = "macos")]
assert!(dec.is_ok(), "AV1 decoder factory should succeed on macOS (dav1d fallback)");
#[cfg(not(target_os = "macos"))]
assert!( assert!(
matches!(dec, Err(VideoError::NotInitialized)), matches!(dec, Err(VideoError::NotInitialized)),
"AV1 decoder requires MediaCodec on Android; non-Android device returns NotInitialized" "AV1 decoder unavailable on Android/Linux (no shiguredo_dav1d)"
);
#[cfg(not(target_os = "android"))]
assert!(
dec.is_ok(),
"AV1 decoder factory should succeed on non-Android (dav1d SW fallback)"
); );
} }

View File

@@ -6,7 +6,7 @@
pub mod av1_obu; pub mod av1_obu;
pub mod controller; pub mod controller;
#[cfg(not(target_os = "android"))] #[cfg(target_os = "macos")]
pub mod dav1d; pub mod dav1d;
pub mod decoder; pub mod decoder;
pub mod depacketizer; pub mod depacketizer;
@@ -17,13 +17,13 @@ pub mod framer;
pub mod mediacodec; pub mod mediacodec;
pub mod nack; pub mod nack;
pub mod simulcast; pub mod simulcast;
#[cfg(not(target_os = "android"))] #[cfg(target_os = "macos")]
pub mod svt_av1; pub mod svt_av1;
pub mod videotoolbox; pub mod videotoolbox;
pub use av1_obu::{Av1Depacketizer, Av1ObuFramer, is_keyframe_obu}; pub use av1_obu::{Av1Depacketizer, Av1ObuFramer, is_keyframe_obu};
pub use controller::{VideoQualityController, VideoTarget}; pub use controller::{VideoQualityController, VideoTarget};
#[cfg(not(target_os = "android"))] #[cfg(target_os = "macos")]
pub use dav1d::Dav1dDecoder; pub use dav1d::Dav1dDecoder;
pub use decoder::VideoDecoder; pub use decoder::VideoDecoder;
pub use depacketizer::H264Depacketizer; pub use depacketizer::H264Depacketizer;
@@ -37,7 +37,7 @@ pub use mediacodec::{
}; };
pub use nack::{CachedPacket, NackAction, NackReceiver, NackSender}; pub use nack::{CachedPacket, NackAction, NackReceiver, NackSender};
pub use simulcast::{LayerPacket, LayerTarget, SimulcastEncoder, SimulcastLayer}; pub use simulcast::{LayerPacket, LayerTarget, SimulcastEncoder, SimulcastLayer};
#[cfg(not(target_os = "android"))] #[cfg(target_os = "macos")]
pub use svt_av1::SvtAv1Encoder; pub use svt_av1::SvtAv1Encoder;
pub use videotoolbox::{ pub use videotoolbox::{
VideoToolboxAv1Decoder, VideoToolboxDecoder, VideoToolboxEncoder, VideoToolboxHevcDecoder, VideoToolboxAv1Decoder, VideoToolboxDecoder, VideoToolboxEncoder, VideoToolboxHevcDecoder,

View File

@@ -56,6 +56,30 @@ fn audio_manager<'local>(
Ok(am) Ok(am)
} }
fn has_permission(permission: &str) -> Result<bool, String> {
let (vm, activity) = jvm_and_activity()?;
let mut env = vm
.attach_current_thread()
.map_err(|e| format!("attach_current_thread: {e}"))?;
let permission = env
.new_string(permission)
.map_err(|e| format!("new_string(permission): {e}"))?;
let result = env
.call_method(
&activity,
"checkSelfPermission",
"(Ljava/lang/String;)I",
&[JValue::Object(&permission)],
)
.and_then(|v| v.i())
.map_err(|e| format!("checkSelfPermission: {e}"))?;
Ok(result == 0)
}
pub fn has_record_audio_permission() -> Result<bool, String> {
has_permission("android.permission.RECORD_AUDIO")
}
/// Set `AudioManager.MODE_IN_COMMUNICATION`. Call when a VoIP call starts. /// Set `AudioManager.MODE_IN_COMMUNICATION`. Call when a VoIP call starts.
/// This tells the audio policy to route through the communication device /// This tells the audio policy to route through the communication device
/// path (earpiece/BT SCO) instead of the media path (speaker/BT A2DP). /// path (earpiece/BT SCO) instead of the media path (speaker/BT A2DP).
@@ -72,6 +96,35 @@ pub fn set_audio_mode_communication() -> Result<(), String> {
Ok(()) Ok(())
} }
/// Run `set_audio_mode_communication` on Tauri's main thread, where the
/// Android context is initialized. Calling it from arbitrary Tokio blocking
/// workers panics inside `ndk_context::android_context()`.
pub async fn set_audio_mode_communication_on_main(
app: tauri::AppHandle,
) -> Result<(), String> {
let (tx, rx) = tokio::sync::oneshot::channel();
app.run_on_main_thread(move || {
let result = std::panic::catch_unwind(set_audio_mode_communication)
.map_err(|panic| {
if let Some(s) = panic.downcast_ref::<&str>() {
format!("panic: {s}")
} else if let Some(s) = panic.downcast_ref::<String>() {
format!("panic: {s}")
} else {
"panic: unknown".to_string()
}
})
.and_then(|r| r);
let _ = tx.send(result);
})
.map_err(|e| format!("run_on_main_thread: {e}"))?;
tokio::time::timeout(std::time::Duration::from_secs(2), rx)
.await
.map_err(|_| "set_audio_mode_communication timed out after 2s".to_string())?
.map_err(|_| "set_audio_mode_communication result channel closed".to_string())?
}
/// Restore `AudioManager.MODE_NORMAL`. Call when a VoIP call ends. /// Restore `AudioManager.MODE_NORMAL`. Call when a VoIP call ends.
pub fn set_audio_mode_normal() -> Result<(), String> { pub fn set_audio_mode_normal() -> Result<(), String> {
let (vm, activity) = jvm_and_activity()?; let (vm, activity) = jvm_and_activity()?;

View File

@@ -133,6 +133,7 @@ fn codec_to_profile(codec: CodecId) -> QualityProfile {
/// Handles RoomUpdate (participant list), QualityDirective (relay-pushed /// Handles RoomUpdate (participant list), QualityDirective (relay-pushed
/// codec switch), and Hangup from the relay signal stream. /// codec switch), and Hangup from the relay signal stream.
async fn run_signal_task( async fn run_signal_task(
app: tauri::AppHandle,
transport: Arc<wzp_transport::QuinnTransport>, transport: Arc<wzp_transport::QuinnTransport>,
running: Arc<AtomicBool>, running: Arc<AtomicBool>,
pending_profile: Arc<AtomicU8>, pending_profile: Arc<AtomicU8>,
@@ -164,7 +165,32 @@ async fn run_signal_task(
}) })
.collect(); .collect();
let count = unique.len(); let count = unique.len();
let event_participants = unique
.iter()
.map(|p| {
serde_json::json!({
"fingerprint": p.fingerprint,
"alias": p.alias,
"relay_label": p.relay_label,
})
})
.collect::<Vec<_>>();
*participants.lock().await = unique; *participants.lock().await = unique;
crate::emit_call_debug(
&app,
"media:room_update",
serde_json::json!({
"participants": event_participants.clone(),
"count": count,
}),
);
let _ = app.emit(
"call-event",
serde_json::json!({
"kind": "participants",
"participants": event_participants,
}),
);
event_cb("room-update", &format!("{count} participants")); event_cb("room-update", &format!("{count} participants"));
} }
Ok(Ok(Some(wzp_proto::SignalMessage::QualityDirective { Ok(Ok(Some(wzp_proto::SignalMessage::QualityDirective {
@@ -544,13 +570,43 @@ impl CallEngine {
// through the signal channel (DirectCallOffer/Answer carry // through the signal channel (DirectCallOffer/Answer carry
// identity_pub + ephemeral_pub + signature). // identity_pub + ephemeral_pub + signature).
if !is_direct_p2p { if !is_direct_p2p {
let _session = crate::emit_call_debug(
wzp_client::handshake::perform_handshake(&*transport, &seed.0, Some(&alias)) &app,
"connect:handshake_start",
serde_json::json!({
"t_ms": call_t0.elapsed().as_millis(),
"room": room,
"remote": transport.remote_address().to_string(),
}),
);
let _session = match wzp_client::handshake::perform_handshake(
&*transport,
&seed.0,
Some(&alias),
)
.await .await
.map_err(|e| { {
Ok(session) => session,
Err(e) => {
error!("perform_handshake failed: {e}"); error!("perform_handshake failed: {e}");
e crate::emit_call_debug(
})?; &app,
"connect:handshake_failed",
serde_json::json!({
"t_ms": call_t0.elapsed().as_millis(),
"error": e.to_string(),
}),
);
return Err(e.into());
}
};
crate::emit_call_debug(
&app,
"connect:handshake_done",
serde_json::json!({
"t_ms": call_t0.elapsed().as_millis(),
}),
);
info!( info!(
t_ms = call_t0.elapsed().as_millis(), t_ms = call_t0.elapsed().as_millis(),
"first-join diag: connected to relay, handshake complete" "first-join diag: connected to relay, handshake complete"
@@ -561,13 +617,35 @@ impl CallEngine {
"first-join diag: direct P2P — skipping relay handshake (QUIC TLS is the encryption layer)" "first-join diag: direct P2P — skipping relay handshake (QUIC TLS is the encryption layer)"
); );
} }
event_cb("connected", &format!("joined room {room}")); // Do not emit the legacy "connected" call-event here. The frontend
// ignores it and enters voice only after the command resolves; on
// Android this synchronous emit was the only operation between
// handshake_done and audio preflight in failing traces.
crate::emit_call_debug(
&app,
"connect:connected_event_skipped",
serde_json::json!({ "t_ms": call_t0.elapsed().as_millis() }),
);
// Oboe audio via the wzp-native cdylib that was dlopen'd at // Oboe audio via the wzp-native cdylib that was dlopen'd at
// startup. `wzp_native::audio_start()` brings up the capture + // startup. `wzp_native::audio_start()` brings up the capture +
// playout streams; send/recv tasks below pull/push PCM through // playout streams; send/recv tasks below pull/push PCM through
// the extern "C" bridge rings. // the extern "C" bridge rings.
if !crate::wzp_native::is_loaded() { crate::emit_call_debug(
&app,
"connect:android_audio_preflight_start",
serde_json::json!({ "t_ms": call_t0.elapsed().as_millis() }),
);
let native_loaded = crate::wzp_native::is_loaded();
crate::emit_call_debug(
&app,
"connect:android_audio_preflight",
serde_json::json!({
"t_ms": call_t0.elapsed().as_millis(),
"wzp_native_loaded": native_loaded,
}),
);
if !native_loaded {
return Err(anyhow::anyhow!( return Err(anyhow::anyhow!(
"wzp-native not loaded — dlopen failed at startup" "wzp-native not loaded — dlopen failed at startup"
)); ));
@@ -584,7 +662,17 @@ impl CallEngine {
// running stop first (no-op on cold start when not yet // running stop first (no-op on cold start when not yet
// started), we get the same "fresh rebuild" behavior on // started), we get the same "fresh rebuild" behavior on
// every call. // every call.
crate::emit_call_debug(
&app,
"connect:audio_stop_start",
serde_json::json!({ "t_ms": call_t0.elapsed().as_millis() }),
);
crate::wzp_native::audio_stop(); crate::wzp_native::audio_stop();
crate::emit_call_debug(
&app,
"connect:audio_stop_done",
serde_json::json!({ "t_ms": call_t0.elapsed().as_millis() }),
);
// Brief pause to let Android's audio routing + AudioManager // Brief pause to let Android's audio routing + AudioManager
// settle after the stop. 50ms is enough for the driver to // settle after the stop. 50ms is enough for the driver to
// release the audio session; shorter risks the new start // release the audio session; shorter risks the new start
@@ -596,13 +684,76 @@ impl CallEngine {
// (music drops from BT A2DP to earpiece, etc.). // (music drops from BT A2DP to earpiece, etc.).
#[cfg(target_os = "android")] #[cfg(target_os = "android")]
{ {
if let Err(e) = crate::android_audio::set_audio_mode_communication() { crate::emit_call_debug(
&app,
"connect:audio_mode_start",
serde_json::json!({ "t_ms": call_t0.elapsed().as_millis() }),
);
match crate::android_audio::set_audio_mode_communication_on_main(app.clone()).await {
Ok(()) => crate::emit_call_debug(
&app,
"connect:audio_mode_done",
serde_json::json!({ "t_ms": call_t0.elapsed().as_millis() }),
),
Err(e) => {
tracing::warn!("set_audio_mode_communication failed: {e}"); tracing::warn!("set_audio_mode_communication failed: {e}");
crate::emit_call_debug(
&app,
"connect:audio_mode_failed",
serde_json::json!({
"t_ms": call_t0.elapsed().as_millis(),
"error": e,
}),
);
}
} }
} }
// Run audio_start on a blocking thread — wzp_oboe_start is a
// sync FFI call that can stall waiting for the Android audio
// HAL. Calling it directly blocks the tokio worker thread,
// which freezes all async tasks including our own timeouts.
let t_pre_audio = call_t0.elapsed().as_millis(); let t_pre_audio = call_t0.elapsed().as_millis();
if let Err(code) = crate::wzp_native::audio_start() { crate::emit_call_debug(
&app,
"connect:audio_start_start",
serde_json::json!({ "t_ms": t_pre_audio }),
);
let audio_start_task = tokio::task::spawn_blocking(crate::wzp_native::audio_start);
let audio_start_result =
match tokio::time::timeout(std::time::Duration::from_secs(8), audio_start_task).await {
Ok(join_result) => join_result.map_err(|e| {
crate::emit_call_debug(
&app,
"connect:audio_start_panic",
serde_json::json!({
"t_ms": call_t0.elapsed().as_millis(),
"error": e.to_string(),
}),
);
anyhow::anyhow!("audio_start task panic: {e}")
})?,
Err(_) => {
crate::emit_call_debug(
&app,
"connect:audio_start_timeout",
serde_json::json!({
"t_ms": call_t0.elapsed().as_millis(),
"timeout_ms": 8000,
}),
);
return Err(anyhow::anyhow!("wzp_native_audio_start timed out after 8s"));
}
};
if let Err(code) = audio_start_result {
crate::emit_call_debug(
&app,
"connect:audio_start_failed",
serde_json::json!({
"t_ms": call_t0.elapsed().as_millis(),
"code": code,
}),
);
return Err(anyhow::anyhow!( return Err(anyhow::anyhow!(
"wzp_native_audio_start failed: code {code}" "wzp_native_audio_start failed: code {code}"
)); ));
@@ -626,6 +777,14 @@ impl CallEngine {
audio_start_ms = t_audio_start_done.saturating_sub(t_pre_audio), audio_start_ms = t_audio_start_done.saturating_sub(t_pre_audio),
"first-join diag: wzp-native audio started (with stop+prime cycle)" "first-join diag: wzp-native audio started (with stop+prime cycle)"
); );
crate::emit_call_debug(
&app,
"connect:audio_start_done",
serde_json::json!({
"t_ms": t_audio_start_done,
"audio_start_ms": t_audio_start_done.saturating_sub(t_pre_audio),
}),
);
let running = Arc::new(AtomicBool::new(true)); let running = Arc::new(AtomicBool::new(true));
let mic_muted = Arc::new(AtomicBool::new(false)); let mic_muted = Arc::new(AtomicBool::new(false));
@@ -1285,6 +1444,7 @@ impl CallEngine {
// Signal task (presence + quality directives). // Signal task (presence + quality directives).
let event_cb = Arc::new(event_cb); let event_cb = Arc::new(event_cb);
tokio::spawn(run_signal_task( tokio::spawn(run_signal_task(
app.clone(),
transport.clone(), transport.clone(),
running.clone(), running.clone(),
pending_profile.clone(), pending_profile.clone(),
@@ -1693,6 +1853,7 @@ impl CallEngine {
// Signal task (presence + quality directives) // Signal task (presence + quality directives)
let event_cb = Arc::new(event_cb); let event_cb = Arc::new(event_cb);
tokio::spawn(run_signal_task( tokio::spawn(run_signal_task(
_app.clone(),
transport.clone(), transport.clone(),
running.clone(), running.clone(),
pending_profile.clone(), pending_profile.clone(),

View File

@@ -59,13 +59,15 @@ fn set_call_debug_logs_internal(on: bool) {
CALL_DEBUG_LOGS.store(on, Ordering::Relaxed); CALL_DEBUG_LOGS.store(on, Ordering::Relaxed);
} }
/// Emit a `call-debug-log` event to the JS side IF the flag is on. /// Emit a `call-debug-log` event to the JS side.
/// Also mirrors to `tracing::info!` so logcat keeps its copy /// Also mirrors to `tracing::info!` so logcat keeps its copy
/// regardless of the flag — the toggle only controls the GUI /// regardless of the flag. Connect/register steps are always emitted
/// overlay, not the underlying Android log stream. /// because they are needed to diagnose failed joins after app data is
/// cleared and the GUI debug toggle is back to its default false value.
pub(crate) fn emit_call_debug(app: &tauri::AppHandle, step: &str, details: serde_json::Value) { pub(crate) fn emit_call_debug(app: &tauri::AppHandle, step: &str, details: serde_json::Value) {
tracing::info!(step, ?details, "call-debug"); tracing::info!(step, ?details, "call-debug");
if !call_debug_logs_enabled() { let force_emit = step.starts_with("connect:") || step.starts_with("register_signal:");
if !force_emit && !call_debug_logs_enabled() {
return; return;
} }
let payload = serde_json::json!({ let payload = serde_json::json!({
@@ -772,6 +774,18 @@ async fn connect(
if reuse_endpoint.is_some() && pre_connected_transport.is_none() { if reuse_endpoint.is_some() && pre_connected_transport.is_none() {
tracing::info!("connect: reusing existing signal endpoint for media connection"); tracing::info!("connect: reusing existing signal endpoint for media connection");
} }
emit_call_debug(
&app,
"connect:reuse_endpoint",
serde_json::json!({
"has_reuse_endpoint": reuse_endpoint.is_some(),
"reuse_local_addr": reuse_endpoint
.as_ref()
.and_then(|ep| ep.local_addr().ok())
.map(|addr| addr.to_string()),
"has_pre_connected_transport": pre_connected_transport.is_some(),
}),
);
let app_clone = app.clone(); let app_clone = app.clone();
// Log transport details for debugging direct P2P media issues // Log transport details for debugging direct P2P media issues

View File

@@ -166,9 +166,57 @@ function getRelay(): RelayServer | null {
let myFingerprint = ""; let myFingerprint = "";
let statusInterval: number | null = null; let statusInterval: number | null = null;
let inVoice = false; let inVoice = false;
let connectPending = false; // guard against double-tap while connect is in-flight
let directCallPeer: { fingerprint: string; alias: string | null } | null = null; let directCallPeer: { fingerprint: string; alias: string | null } | null = null;
let pendingCallId: string | null = null; let pendingCallId: string | null = null;
function showToast(msg: string, durationMs = 3500) {
let el = document.getElementById("wzp-toast");
if (!el) {
el = document.createElement("div");
el.id = "wzp-toast";
el.style.cssText = "position:fixed;bottom:80px;left:50%;transform:translateX(-50%);" +
"background:#1e1e2e;color:#cdd6f4;border:1px solid #45475a;border-radius:8px;" +
"padding:10px 18px;font-size:13px;z-index:9999;pointer-events:none;opacity:0;transition:opacity .2s";
document.body.appendChild(el);
}
el.textContent = msg;
el.style.opacity = "1";
clearTimeout((el as any)._timer);
(el as any)._timer = setTimeout(() => { el!.style.opacity = "0"; }, durationMs);
}
function errorMessage(e: unknown): string {
if (typeof e === "string") return e;
if (e && typeof e === "object" && "message" in e) {
const msg = (e as { message?: unknown }).message;
if (typeof msg === "string") return msg;
}
return String(e);
}
function connectDebugSummary(entry: CallDebugEntry | null): string {
if (!entry) return "no native connect event received";
const details = entry.details && typeof entry.details === "object"
? JSON.stringify(entry.details)
: String(entry.details ?? "");
return `${entry.step}${details ? ` ${details}` : ""}`;
}
let lastConnectDebug: CallDebugEntry | null = null;
function connectWithTimeout(args: Record<string, unknown>, timeoutMs = 45000) {
lastConnectDebug = null;
return Promise.race([
invoke("connect", args),
new Promise<never>((_, reject) =>
setTimeout(() => reject(new Error(
`connect timed out (${Math.round(timeoutMs / 1000)}s); last native step: ${connectDebugSummary(lastConnectDebug)}`
)), timeoutMs)
),
]);
}
// Known users in the room (from RoomUpdate or signal presence) // Known users in the room (from RoomUpdate or signal presence)
interface LobbyUser { interface LobbyUser {
fingerprint: string; fingerprint: string;
@@ -186,6 +234,7 @@ const CALL_DEBUG_MAX = 200;
listen("call-debug-log", (event: any) => { listen("call-debug-log", (event: any) => {
const entry: CallDebugEntry = event.payload; const entry: CallDebugEntry = event.payload;
callDebugBuffer.push(entry); callDebugBuffer.push(entry);
if (entry.step?.startsWith("connect:")) lastConnectDebug = entry;
if (callDebugBuffer.length > CALL_DEBUG_MAX) callDebugBuffer.shift(); if (callDebugBuffer.length > CALL_DEBUG_MAX) callDebugBuffer.shift();
renderCallDebugLog(); renderCallDebugLog();
}); });
@@ -309,12 +358,16 @@ ctxCallBtn.addEventListener("click", async () => {
// ── Voice join/leave (drawer-based) ─────────────────────────────── // ── Voice join/leave (drawer-based) ───────────────────────────────
joinVoiceBtn.addEventListener("click", async () => { joinVoiceBtn.addEventListener("click", async () => {
if (inVoice) return; if (inVoice || connectPending) return;
const relay = getRelay(); const relay = getRelay();
const s = loadSettings(); const s = loadSettings();
if (!relay) return; if (!relay) { showToast("No relay configured"); return; }
connectPending = true;
const origText = joinVoiceBtn.textContent;
joinVoiceBtn.textContent = "Connecting…";
(joinVoiceBtn as HTMLButtonElement).disabled = true;
try { try {
await invoke("connect", { await connectWithTimeout({
relay: relay.address, relay: relay.address,
room: s.room || "general", room: s.room || "general",
alias: s.alias || "", alias: s.alias || "",
@@ -324,6 +377,11 @@ joinVoiceBtn.addEventListener("click", async () => {
enterVoice(false); enterVoice(false);
} catch (e: any) { } catch (e: any) {
console.error("connect failed:", e); console.error("connect failed:", e);
showToast(`Join failed: ${errorMessage(e)}`);
} finally {
connectPending = false;
joinVoiceBtn.textContent = origText;
(joinVoiceBtn as HTMLButtonElement).disabled = false;
} }
}); });
@@ -481,9 +539,11 @@ listen("signal-event", (event: any) => {
incomingBanner.classList.add("hidden"); incomingBanner.classList.add("hidden");
// Auto-connect to the call // Auto-connect to the call
(async () => { (async () => {
if (connectPending) return;
connectPending = true;
const s = loadSettings(); const s = loadSettings();
try { try {
await invoke("connect", { await connectWithTimeout({
relay: data.relay_addr, relay: data.relay_addr,
room: data.room, room: data.room,
alias: s.alias || "", alias: s.alias || "",
@@ -498,6 +558,9 @@ listen("signal-event", (event: any) => {
enterVoice(true); enterVoice(true);
} catch (e: any) { } catch (e: any) {
console.error("connect failed:", e); console.error("connect failed:", e);
showToast(`Call failed to connect: ${errorMessage(e)}`);
} finally {
connectPending = false;
} }
})(); })();
break; break;

View File

@@ -0,0 +1,192 @@
# BUG-001: Android "Connecting…" Hangs / Join Voice Never Completes
**Severity:** P0 — renders the app non-functional for room joins on a fresh install
**Status:** Partially mitigated (5a13f12), narrowed by static review; Android repro/logcat still needed
**Branch:** `experimental-ui`
**Last investigated:** 2026-05-25
**Device confirmed affected:** Nothing Phone A059 (Android 15)
---
## Symptom
User taps "Join Voice". Button changes to "Connecting…" and stays there indefinitely. No error toast, no drawer, no progress. The only recovery is force-quitting the app.
## 2026-05-25 Static Review Update
The exact indefinite "Connecting…" symptom most likely came from an APK older than `5a13f12`, because current `desktop/src/main.ts` has a 15s JS-side timeout for manual room joins. The current branch can still produce closely related failures:
1. Native Oboe start can report false success when Android leaves capture/playout in `Starting` for 2s. That manifests as "joined but silent/dead audio", not a true JS hang.
2. First-run microphone permission can still race the first `openStream(Direction::Input)`, especially when the user joins immediately after granting permission.
3. Direct-call auto-connect did not have the 15s JS timeout even after `5a13f12`.
4. Toasts used `${e}`, so object-shaped Tauri errors could appear as `[object Object]`.
Working-tree diagnostic changes applied during this investigation:
- `crates/wzp-native/cpp/oboe_bridge.cpp`: return `-6` if both streams do not reach `Started` before the 2s poll deadline. This turns Oboe false-success into a visible Rust/JS error.
- `desktop/src/main.ts`: shared `connectWithTimeout()` for room joins and direct-call auto-connect; shared `errorMessage()` for useful toast text.
- `desktop/src-tauri/src/engine.rs`: emit `connect:handshake_*`, `connect:android_audio_preflight`, `connect:audio_*` markers around each Android-only join step.
- `desktop/src-tauri/src/lib.rs`: emit `connect:reuse_endpoint` so we can see whether the room join is sharing the signal QUIC endpoint.
Next Android repro should distinguish:
| Toast / log | Meaning |
|---|---|
| `Join failed: wzp_native_audio_start failed: code -2` | mic permission / capture open failure |
| `Join failed: wzp_native_audio_start failed: code -6` | Oboe streams opened/requested start, but HAL never transitioned both to `Started` |
| `Join failed: transport: timeout after 10000ms` or similar after `connect:handshake_start` | QUIC connected, but relay media handshake did not return `CallAnswer` |
| `Join failed: connect timed out (15s) - check audio permissions` | Tauri command did not resolve to JS; collect Rust/Tauri logs around `connect:call_engine_starting` |
---
## Root Cause Chain
The `invoke("connect")` Tauri command runs the full `CallEngine::start` coroutine on Android. Execution order:
1. Parse relay address → QUIC dial → crypto handshake (~200ms, works — relay logs confirm room join succeeds)
2. `audio_stop()` (no-op on first launch)
3. `tokio::time::sleep(50ms)`
4. `set_audio_mode_communication()` (JNI into Kotlin)
5. **`tokio::task::spawn_blocking(crate::wzp_native::audio_start)`** ← primary hang point
`audio_start` calls `wzp_oboe_start()` (C++ FFI in `crates/wzp-native/cpp/oboe_bridge.cpp`), which:
- Opens capture stream (`captureBuilder.openStream`)
- Opens playout stream (`playoutBuilder.openStream`)
- `g_capture_stream->requestStart()`
- `g_playout_stream->requestStart()`
- **Polls up to 2 seconds** in a `std::this_thread::sleep_for(10ms)` busy-wait loop waiting for both streams to reach `Started` state (`oboe_bridge.cpp:404423`)
Before the working-tree `-6` diagnostic change, if the HAL never transitioned to `Started`, `wzp_oboe_start` returned 0 (success!) after the 2s timeout even though streams were not functional. Rust saw `ret == 0`, considered it success, and `CallEngine::start` returned `Ok`.
The `invoke("connect")` promise resolves successfully, `enterVoice(false)` is called, the voice drawer appears — but audio streams are dead. The send task reads silence, the playout ring never drains.
**However**, relay log evidence shows the connection is established and then dropped 166ms later with `forwarded=0`, which means `CallEngine::start` did return to the `connect` command. If the user still sees "Connecting…" at that point, the JS `await connectRace` is not resolving — suggesting either the Rust command returned an error (which should show as a toast) or the `invoke` promise is hanging for a different reason.
---
## Evidence
**Relay log (pangolin, session at 06:40:04 UTC):**
```
room "general" join accepted
crypto handshake complete t=+184ms
connection dropped t=+350ms forwarded=0
```
The relay sees a clean connection that self-terminates in ~350ms total. `forwarded=0` means no media was exchanged. Consistent with audio_start failing or the call task throwing before media loops start.
**Four rapid connects at 06:40:04** in the relay log suggest multiple taps (no `connectPending` guard in the APK installed at that time, or user was on an older build).
---
## Fixes Applied in `5a13f12`
| # | Problem | Fix | File |
|---|---------|-----|------|
| 1 | `wzp_oboe_start` called directly on tokio worker thread → froze entire runtime including timeouts | Changed to `spawn_blocking` | `desktop/src-tauri/src/engine.rs:609` |
| 2 | No JS-side timeout → "Connecting…" hangs forever if Rust never returns | Added 15s `Promise.race` | `desktop/src/main.ts:338` |
| 3 | No error feedback to user | Added `showToast()` in `catch` block | `desktop/src/main.ts:352` |
| 4 | Button disappeared on click | Changed to `disabled + "Connecting…"` text | `desktop/src/main.ts:335` |
| 5 | Handshake could hang forever waiting for `CallAnswer` | Added 10s `tokio::time::timeout` | `crates/wzp-client/src/handshake.rs:105` |
---
## Open Issues (Not Yet Fixed)
### Issue A: `g_running` flag race between `audio_stop` and `audio_start`
**Current status:** likely fixed in current branch. `crates/wzp-native/cpp/oboe_bridge.cpp:430` now clears `g_running` at the top of `wzp_oboe_stop`.
`oboe_bridge.cpp:244` checks `g_running.load()` at entry to `wzp_oboe_start`. The engine calls `audio_stop()` then waits 50ms then calls `audio_start()`. If `wzp_oboe_stop` does not synchronously clear `g_running` before returning, the next `wzp_oboe_start` sees `g_running == true` and returns `-1` immediately (line 246247).
With `5a13f12`, Rust now propagates this as `"wzp_native_audio_start failed: code -1"` → toast. Confirm via logcat.
### Issue B: Mic permission granted at runtime causes audio HAL delay
After clearing app data, Android prompts for mic permission. The OS grants it but the audio HAL may not immediately honor it. The first `openStream(Direction::Input)` within ~1s of permission grant can fail with `ErrorPermissionDenied` → Oboe returns `-2`.
With `5a13f12` this should surface as toast: `"Join failed: wzp_native_audio_start failed: code -2"`.
### Issue C: `wzp_oboe_start` 2s poll timeout returns 0 (false success)
`oboe_bridge.cpp:404423`: if streams don't reach `Started` state within 2s, the poll loop exits with no error — `wzp_oboe_start` returns 0. Rust treats this as success. The drawer appears but audio is dead. This is the "joined but silent" failure mode, distinct from "stuck on Connecting…".
**Fix:** return a distinct error code (e.g. `-6`) from `wzp_oboe_start` when the poll times out without both streams reaching `Started`.
**Working-tree status:** implemented as `-6`; needs Android NDK/device validation.
### Issue D: Error object serialization in JS toast
The `connect` command returns `Result<String, String>`. Tauri wraps the `Err` as a JS exception. If `e` in the `catch` block is a Tauri error object rather than a plain string, `${e}` renders as `"[object Object]"`. Should use `e?.message ?? String(e)` for robust stringification.
**Working-tree status:** implemented via `errorMessage(e)`.
---
## `wzp_oboe_start` Return Codes Reference
| Code | Meaning |
|------|---------|
| 0 | Success |
| -1 | Already running (`g_running == true` at entry) |
| -2 | `captureBuilder.openStream` failed |
| -3 | `playoutBuilder.openStream` failed |
| -4 | `g_capture_stream->requestStart()` failed |
| -5 | `g_playout_stream->requestStart()` failed |
| -6 | streams failed to reach `Started` before poll timeout |
---
## Reproduction Steps
1. Fresh install (or clear app data) on Nothing Phone A059
2. Grant microphone permission when prompted
3. Configure relay `193.180.213.68:4433`, room `general`
4. Tap "Join Voice"
5. Observe: button shows "Connecting…" indefinitely
---
## Diagnostic Steps
We have never captured `adb logcat` from a failing connect. This is the single highest-value diagnostic:
```bash
adb logcat -s "wzp-native" "wzp-desktop" "RustStd" | grep -E "audio|oboe|start|handshake|connect"
```
Key log lines to look for:
| Log line | Diagnosis |
|----------|-----------|
| `connect:reuse_endpoint` | Whether media is sharing the existing signal endpoint |
| `connect:handshake_start` followed by 10s timeout | Relay media handshake is stuck before Android audio starts |
| `connect:handshake_done` | Network/relay handshake succeeded; continue to audio diagnostics |
| `connect:android_audio_preflight` | Shows `wzp-native` load state and RECORD_AUDIO permission |
| `connect:audio_start_start` with no done/failed | Native Oboe call is hanging |
| `wzp_oboe_start: already running` | Issue A — g_running not cleared |
| `Failed to open capture stream: ErrorPermissionDenied` | Issue B — mic permission delay |
| `Failed to start capture` / `Failed to start playout` | Oboe HAL error, code -4 or -5 |
| `both streams Started after N polls` | audio_start succeeded |
| `audio_start task panic` | spawn_blocking panic (shouldn't happen) |
| `wzp_native_audio_start failed: code X` | Rust caught it, toast should be visible |
Alternatively: enable **Call debug logs** in Settings, reproduce, use the share button to extract logs without USB.
---
## Proposed Fixes (Prioritized)
1. **Validate `-6` from `wzp_oboe_start` on poll timeout** on Android builder/device — eliminates silent false-success
2. **Add mic permission pre-check** in Kotlin before calling into Rust — surface a cleaner error if permission is not yet effective
3. **If `-6` reproduces on Nothing A059, test startup sequencing:** request/start capture before `MODE_IN_COMMUNICATION`, add a short post-permission delay, or retry once after a full `wzp_oboe_stop`
---
## Related Files
- `crates/wzp-native/cpp/oboe_bridge.cpp``wzp_oboe_start` implementation
- `crates/wzp-native/src/lib.rs:238``audio_start_inner` (Rust FFI wrapper)
- `desktop/src-tauri/src/engine.rs:576635``CallEngine::start` audio section
- `desktop/src/main.ts:328360``joinVoiceBtn` click handler
- `crates/wzp-client/src/handshake.rs:105` — handshake timeout

122
scripts/android-build-async.sh Executable file
View File

@@ -0,0 +1,122 @@
#!/usr/bin/env bash
# Fire-and-forget Android APK builder.
#
# Uploads the build script to SepehrHomeserverdk, starts it in a tmux
# session so it survives SSH disconnects, then exits immediately.
# Progress and the finished APK URL arrive via ntfy.sh/wzp.
#
# Usage:
# ./scripts/android-build-async.sh # build current branch, arm64
# ./scripts/android-build-async.sh --init # also run cargo tauri android init
# ./scripts/android-build-async.sh --rust # force-clean Rust target cache
# ./scripts/android-build-async.sh --no-pull # skip git fetch on remote
# ./scripts/android-build-async.sh --wait # block until done, then download APK
#
# When the build finishes, ntfy.sh/wzp will show:
# "WZP Tauri arm64 [<hash>] ready! <rustypaste-url>"
# or on failure:
# "WZP Tauri Android build FAILED [<hash>] (line N) log: <url>"
set -euo pipefail
REMOTE_HOST="SepehrHomeserverdk"
NTFY_TOPIC="https://ntfy.sh/wzp"
LOCAL_OUTPUT="target/tauri-android-apk"
TMUX_SESSION="wzp-android"
REMOTE_LOG="/tmp/wzp-tauri-build.log"
SSH_OPTS="-o ConnectTimeout=15 -o ServerAliveInterval=30 -o ServerAliveCountMax=6 -o LogLevel=ERROR"
BRANCH="${WZP_BRANCH:-$(git -C "$(dirname "$0")/.." branch --show-current 2>/dev/null || echo "")}"
DO_PULL=1
DO_INIT=0
BUILD_RELEASE=1
REBUILD_RUST=0
BUILD_ARCH="arm64"
DO_WAIT=0
for arg in "$@"; do
case "$arg" in
--pull) DO_PULL=1 ;;
--no-pull) DO_PULL=0 ;;
--init) DO_INIT=1 ;;
--debug) BUILD_RELEASE=0 ;;
--rust) REBUILD_RUST=1 ;;
--wait) DO_WAIT=1 ;;
esac
done
if [ -z "$BRANCH" ]; then
echo "ERROR: could not determine branch (detached HEAD?). Set WZP_BRANCH=name."
exit 1
fi
log() { echo -e "\033[1;36m>>> $*\033[0m"; }
err() { echo -e "\033[1;31mERROR: $*\033[0m" >&2; }
ssh_q() { ssh $SSH_OPTS "$REMOTE_HOST" "$@"; }
# ── Step 1: upload the remote build script ──────────────────────────────────
log "Uploading build script to $REMOTE_HOST..."
# Re-use the existing full build script (it already handles all logic).
scp $SSH_OPTS "$(dirname "$0")/build-tauri-android.sh" "$REMOTE_HOST:/tmp/wzp-tauri-build-full.sh"
ssh_q "chmod +x /tmp/wzp-tauri-build-full.sh"
# ── Step 2: launch in tmux (detached) ──────────────────────────────────────
log "Starting build in tmux session '$TMUX_SESSION' on $REMOTE_HOST..."
ssh_q "tmux kill-session -t $TMUX_SESSION 2>/dev/null; true"
# The full script accepts flags directly; pass them through.
REMOTE_FLAGS=""
[ "$DO_PULL" = "1" ] || REMOTE_FLAGS="$REMOTE_FLAGS --no-pull"
[ "$DO_INIT" = "1" ] && REMOTE_FLAGS="$REMOTE_FLAGS --init"
[ "$BUILD_RELEASE" = "0" ] && REMOTE_FLAGS="$REMOTE_FLAGS --debug"
[ "$REBUILD_RUST" = "1" ] && REMOTE_FLAGS="$REMOTE_FLAGS --rust"
# Run via WZP_BRANCH so the remote script picks up the right branch
# (it calls `git branch --show-current` which would return the remote's
# currently checked-out branch, not necessarily the one we want).
ssh_q "tmux new-session -d -s $TMUX_SESSION \
'WZP_BRANCH=$BRANCH bash /tmp/wzp-tauri-build-full.sh $REMOTE_FLAGS \
2>&1 | tee $REMOTE_LOG; echo DONE_EXIT_CODE=\$? >> $REMOTE_LOG'"
log "Build dispatched! Notification on ntfy.sh/wzp when done."
echo ""
echo " Monitor : ssh $REMOTE_HOST 'tail -f $REMOTE_LOG'"
echo " Status : ssh $REMOTE_HOST 'tail -5 $REMOTE_LOG'"
echo " Attach : ssh $REMOTE_HOST 'tmux attach -t $TMUX_SESSION'"
echo ""
# ── Step 3 (optional --wait): block until done, download APK ───────────────
if [ "$DO_WAIT" = "0" ]; then
exit 0
fi
log "Waiting for build to finish (monitoring $REMOTE_LOG)..."
ssh_q "until grep -qE 'APK_REMOTE_PATH|FAILED|ERROR|DONE_EXIT_CODE' \
$REMOTE_LOG 2>/dev/null; do sleep 20; done"
# Check for failure
if ssh_q "grep -q 'FAILED\|ERROR' $REMOTE_LOG 2>/dev/null" && \
! ssh_q "grep -q 'APK_REMOTE_PATH' $REMOTE_LOG 2>/dev/null"; then
err "Build failed — check ntfy or: ssh $REMOTE_HOST 'cat $REMOTE_LOG'"
exit 1
fi
# Grab APK paths from log
APK_REMOTES=$(ssh_q "grep '^APK_REMOTE_PATH=' $REMOTE_LOG | cut -d= -f2-")
if [ -z "$APK_REMOTES" ]; then
err "No APK_REMOTE_PATH in log — build may have failed silently"
ssh_q "tail -20 $REMOTE_LOG" >&2
exit 1
fi
mkdir -p "$LOCAL_OUTPUT"
echo "$APK_REMOTES" | while IFS= read -r REMOTE_PATH; do
[ -z "$REMOTE_PATH" ] && continue
APK_NAME=$(basename "$REMOTE_PATH")
log "Downloading $APK_NAME..."
scp $SSH_OPTS "$REMOTE_HOST:$REMOTE_PATH" "$LOCAL_OUTPUT/$APK_NAME"
echo " $LOCAL_OUTPUT/$APK_NAME ($(du -h "$LOCAL_OUTPUT/$APK_NAME" | cut -f1))"
done
log "Done! APKs in $LOCAL_OUTPUT/"
ls -lh "$LOCAL_OUTPUT"/wzp-tauri-*.apk 2>/dev/null || true

View File

@@ -10,6 +10,7 @@ set -euo pipefail
# ./scripts/build-linux-docker.sh --pull Git pull before building # ./scripts/build-linux-docker.sh --pull Git pull before building
# ./scripts/build-linux-docker.sh --clean Clean Rust target cache # ./scripts/build-linux-docker.sh --clean Clean Rust target cache
# ./scripts/build-linux-docker.sh --install Download binaries locally after build # ./scripts/build-linux-docker.sh --install Download binaries locally after build
# ./scripts/build-linux-docker.sh --deploy Download + deploy wzp-relay to relay servers
REMOTE_HOST="SepehrHomeserverdk" REMOTE_HOST="SepehrHomeserverdk"
BASE_DIR="/mnt/storage/manBuilder" BASE_DIR="/mnt/storage/manBuilder"
@@ -21,17 +22,26 @@ SSH_OPTS="-o ConnectTimeout=15 -o ServerAliveInterval=15 -o ServerAliveCountMax=
# (opus-DRED-v2 as of 2026-04-11). Override with `WZP_BRANCH=<name> ./build-linux-docker.sh` # (opus-DRED-v2 as of 2026-04-11). Override with `WZP_BRANCH=<name> ./build-linux-docker.sh`
# if you need a different one — e.g. to rebuild the relay from a feature # if you need a different one — e.g. to rebuild the relay from a feature
# branch for A/B testing. # branch for A/B testing.
WZP_BRANCH="${WZP_BRANCH:-opus-DRED-v2}" WZP_BRANCH="${WZP_BRANCH:-$(git -C "$(dirname "$0")/.." branch --show-current 2>/dev/null || echo "experimental-ui")}"
# Relay servers to deploy to when --deploy is passed.
# Format: "user@host:binary_dir:tmux_session"
RELAY_SERVERS=(
"manwe@manwehs:/home/manwe/wzp:5"
"manwe@pangolin.manko.yoga:/home/manwe/wzp-linux:0"
)
DO_PULL=1 DO_PULL=1
DO_CLEAN=0 DO_CLEAN=0
DO_INSTALL=0 DO_INSTALL=0
DO_DEPLOY=0
for arg in "$@"; do for arg in "$@"; do
case "$arg" in case "$arg" in
--pull) DO_PULL=1 ;; --pull) DO_PULL=1 ;;
--no-pull) DO_PULL=0 ;; --no-pull) DO_PULL=0 ;;
--clean) DO_CLEAN=1 ;; --clean) DO_CLEAN=1 ;;
--install) DO_INSTALL=1 ;; --install) DO_INSTALL=1 ;;
--deploy) DO_DEPLOY=1; DO_INSTALL=1 ;;
esac esac
done done
@@ -95,20 +105,15 @@ docker run --rm --user 1000:1000 \
set -euo pipefail set -euo pipefail
cd /build/source cd /build/source
echo ">>> Building relay + client + web + bench..." echo ">>> Building relay + web..."
cargo build --release --bin wzp-relay --bin wzp-client --bin wzp-web --bin wzp-bench 2>&1 | tail -5 cargo build --release --bin wzp-relay --bin wzp-web 2>&1 | tail -5
echo ">>> Building audio client..."
cargo build --release --bin wzp-client --features audio 2>&1 | tail -3
cp target/release/wzp-client target/release/wzp-client-audio
cargo build --release --bin wzp-client 2>&1 | tail -3
echo ">>> Binaries:" echo ">>> Binaries:"
ls -lh target/release/wzp-relay target/release/wzp-client target/release/wzp-client-audio target/release/wzp-web target/release/wzp-bench ls -lh target/release/wzp-relay target/release/wzp-web
echo ">>> Packaging..." echo ">>> Packaging..."
tar czf /tmp/wzp-linux-x86_64.tar.gz \ tar czf /tmp/wzp-linux-x86_64.tar.gz \
-C target/release wzp-relay wzp-client wzp-client-audio wzp-web wzp-bench -C target/release wzp-relay wzp-web
echo "BINARIES_BUILT" echo "BINARIES_BUILT"
' '
@@ -121,7 +126,7 @@ TARBALL="$BASE_DIR/data/cache-linux/target/release/../../../wzp-linux-x86_64.tar
docker run --rm \ docker run --rm \
-v "$BASE_DIR/data/cache-linux/target:/build/target" \ -v "$BASE_DIR/data/cache-linux/target:/build/target" \
wzp-android-builder bash -c \ wzp-android-builder bash -c \
"cp /build/target/release/wzp-relay /build/target/release/wzp-client /build/target/release/wzp-client-audio /build/target/release/wzp-web /build/target/release/wzp-bench /tmp/ && tar czf /tmp/wzp-linux-x86_64.tar.gz -C /tmp wzp-relay wzp-client wzp-client-audio wzp-web wzp-bench && cat /tmp/wzp-linux-x86_64.tar.gz" \ "cp /build/target/release/wzp-relay /build/target/release/wzp-web /tmp/ && tar czf /tmp/wzp-linux-x86_64.tar.gz -C /tmp wzp-relay wzp-web && cat /tmp/wzp-linux-x86_64.tar.gz" \
> /tmp/wzp-linux-x86_64.tar.gz > /tmp/wzp-linux-x86_64.tar.gz
URL=$(curl -s -F "file=@/tmp/wzp-linux-x86_64.tar.gz" -H "Authorization: $rusty_auth_token" "$rusty_address") URL=$(curl -s -F "file=@/tmp/wzp-linux-x86_64.tar.gz" -H "Authorization: $rusty_auth_token" "$rusty_address")
@@ -149,6 +154,46 @@ echo " Monitor: ssh $REMOTE_HOST 'tail -f /tmp/wzp-linux-build.log'"
echo " Status: ssh $REMOTE_HOST 'tail -5 /tmp/wzp-linux-build.log'" echo " Status: ssh $REMOTE_HOST 'tail -5 /tmp/wzp-linux-build.log'"
echo "" echo ""
# Deploy wzp-relay to a single relay server.
# $1 = "user@host" $2 = binary_dir $3 = tmux_session
deploy_relay() {
local TARGET="$1"
local BINARY_DIR="$2"
local TMUX_SESSION="$3"
local DEPLOY_OPTS="-o ConnectTimeout=15 -o StrictHostKeyChecking=accept-new -o LogLevel=ERROR"
log "Deploying wzp-relay to $TARGET ($BINARY_DIR) ..."
# Copy new binary atomically
scp $DEPLOY_OPTS "$LOCAL_OUTPUT/wzp-relay" "$TARGET:$BINARY_DIR/wzp-relay.new"
ssh $DEPLOY_OPTS "$TARGET" "chmod +x $BINARY_DIR/wzp-relay.new && mv $BINARY_DIR/wzp-relay.new $BINARY_DIR/wzp-relay"
# Capture current args, stop, restart in same tmux session
ssh $DEPLOY_OPTS "$TARGET" bash <<DEPLOY
set -euo pipefail
RELAY_PID=\$(pgrep -f './wzp-relay' | head -1 || true)
if [ -z "\$RELAY_PID" ]; then
echo "WARNING: no running wzp-relay found on $TARGET — binary replaced, start it manually"
exit 0
fi
# Capture args from /proc (everything after the binary name)
RELAY_ARGS=\$(tr '\\0' ' ' < /proc/\$RELAY_PID/cmdline | sed 's|^[^ ]* ||; s| *\$||')
echo "Stopping relay PID \$RELAY_PID (args: \$RELAY_ARGS)"
tmux send-keys -t $TMUX_SESSION C-c 2>/dev/null || kill -TERM \$RELAY_PID 2>/dev/null || true
sleep 2
echo "Starting new relay..."
tmux send-keys -t $TMUX_SESSION "cd $BINARY_DIR && ./wzp-relay \$RELAY_ARGS" Enter 2>/dev/null || true
echo "Deploy done on $TARGET"
DEPLOY
# Get the running version and notify
local DEPLOYED_VER
DEPLOYED_VER=$(ssh $DEPLOY_OPTS "$TARGET" "$BINARY_DIR/wzp-relay --version 2>/dev/null | awk '{print \$2}'" || echo "unknown")
curl -s -d "wzp-relay deployed to ${TARGET%%:*} — version $DEPLOYED_VER" "$NTFY_TOPIC" > /dev/null 2>&1 || true
log "Deployed to $TARGET"
}
# Optionally wait and download # Optionally wait and download
if [ "$DO_INSTALL" = "1" ]; then if [ "$DO_INSTALL" = "1" ]; then
log "Waiting for build..." log "Waiting for build..."
@@ -170,5 +215,19 @@ if [ "$DO_INSTALL" = "1" ]; then
log "Done! Binaries in $LOCAL_OUTPUT/" log "Done! Binaries in $LOCAL_OUTPUT/"
else else
err "Build failed" err "Build failed"
exit 1
fi fi
fi fi
# Deploy to relay servers
if [ "$DO_DEPLOY" = "1" ]; then
if [ ! -f "$LOCAL_OUTPUT/wzp-relay" ]; then
err "wzp-relay binary not found in $LOCAL_OUTPUT — install step may have failed"
exit 1
fi
for SERVER in "${RELAY_SERVERS[@]}"; do
IFS=: read -r TARGET BINARY_DIR TMUX_SESSION <<< "$SERVER"
deploy_relay "$TARGET" "$BINARY_DIR" "$TMUX_SESSION"
done
log "All relay servers updated!"
fi

View File

@@ -15,8 +15,8 @@ set -euo pipefail
# - Output: desktop/src-tauri/gen/android/.../*.apk # - Output: desktop/src-tauri/gen/android/.../*.apk
# #
# Usage: # Usage:
# ./scripts/build-tauri-android.sh # full pipeline (debug, arm64 only) # ./scripts/build-tauri-android.sh # full pipeline (release, arm64 only)
# ./scripts/build-tauri-android.sh --release # release APK # ./scripts/build-tauri-android.sh --debug # debug APK (faster, no optimisation)
# ./scripts/build-tauri-android.sh --no-pull # skip git fetch # ./scripts/build-tauri-android.sh --no-pull # skip git fetch
# ./scripts/build-tauri-android.sh --rust # force-clean rust target # ./scripts/build-tauri-android.sh --rust # force-clean rust target
# ./scripts/build-tauri-android.sh --init # also run `cargo tauri android init` # ./scripts/build-tauri-android.sh --init # also run `cargo tauri android init`
@@ -38,7 +38,7 @@ SSH_OPTS="-o ConnectTimeout=15 -o ServerAliveInterval=15 -o ServerAliveCountMax=
REBUILD_RUST=0 REBUILD_RUST=0
DO_PULL=1 DO_PULL=1
DO_INIT=0 DO_INIT=0
BUILD_RELEASE=0 BUILD_RELEASE=1
BUILD_ARCH="arm64" BUILD_ARCH="arm64"
NEXT_IS_ARCH=0 NEXT_IS_ARCH=0
for arg in "$@"; do for arg in "$@"; do
@@ -52,7 +52,7 @@ for arg in "$@"; do
--pull) DO_PULL=1 ;; --pull) DO_PULL=1 ;;
--no-pull) DO_PULL=0 ;; --no-pull) DO_PULL=0 ;;
--init) DO_INIT=1 ;; --init) DO_INIT=1 ;;
--release) BUILD_RELEASE=1 ;; --debug) BUILD_RELEASE=0 ;;
--arch) NEXT_IS_ARCH=1 ;; --arch) NEXT_IS_ARCH=1 ;;
-h|--help) -h|--help)
sed -n '3,32p' "$0" sed -n '3,32p' "$0"
@@ -321,6 +321,31 @@ for ARCH in $ARCHS; do
echo ">>> cargo tauri android build ${PROFILE_FLAG} --target $TARGET --apk" echo ">>> cargo tauri android build ${PROFILE_FLAG} --target $TARGET --apk"
cargo tauri android build ${PROFILE_FLAG} --target "$TARGET" --apk cargo tauri android build ${PROFILE_FLAG} --target "$TARGET" --apk
# ─── Workaround: Tauri CLI 2.10.x does not copy frontendDist to the
# Android assets folder. The Rust build step writes tauri.conf.json
# there correctly, but index.html and the JS/CSS assets are never
# transferred, causing the WebView to fail with "Asset not found:
# index.html" at runtime.
#
# Fix: inject the missing files directly into the unsigned APK (which
# is just a ZIP file). The existing zipalign + apksigner step below
# handles realignment and signing, so this produces a valid APK.
# Re-running Gradle is NOT used here because the Gradle Rust build
# task (BuildTask.kt) calls `cargo tauri android android-studio-script`
# which requires the full Tauri CLI environment and fails standalone.
UNSIGNED_APK_PATH="gen/android/app/build/outputs/apk/universal/release/app-universal-release-unsigned.apk"
if [ -f "$UNSIGNED_APK_PATH" ] && ! unzip -l "$UNSIGNED_APK_PATH" 2>/dev/null | grep -q "assets/index.html"; then
echo ">>> frontend assets missing from APK — patching unsigned APK directly"
PATCH_DIR="/tmp/apk-frontend-patch-$$"
rm -rf "$PATCH_DIR"
mkdir -p "$PATCH_DIR/assets"
cp -r /build/source/desktop/dist/. "$PATCH_DIR/assets/"
(cd "$PATCH_DIR" && zip -r /build/source/desktop/src-tauri/"$UNSIGNED_APK_PATH" assets/)
rm -rf "$PATCH_DIR"
echo ">>> APK patched: $(ls -lh "$UNSIGNED_APK_PATH" | awk "{print \$5}")"
echo ">>> assets in APK: $(unzip -l "$UNSIGNED_APK_PATH" | grep "assets/" | wc -l) entries"
fi
# Copy produced APK with arch suffix # Copy produced APK with arch suffix
BUILT_APK=$(find gen/android -name "*.apk" -newer "$APK_OUTPUT_DIR" -type f 2>/dev/null | head -1) BUILT_APK=$(find gen/android -name "*.apk" -newer "$APK_OUTPUT_DIR" -type f 2>/dev/null | head -1)
if [ -z "$BUILT_APK" ]; then if [ -z "$BUILT_APK" ]; then