Compare commits
18 Commits
15af58a95d
...
experiment
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
01f55caa96 | ||
|
|
0f93a2b745 | ||
|
|
2b93bd4b45 | ||
|
|
bc021517c0 | ||
|
|
739bdaf3ab | ||
|
|
bc1668ed96 | ||
|
|
77b036439b | ||
|
|
0ebc73ab13 | ||
|
|
394987a349 | ||
|
|
2aa6582585 | ||
|
|
ca987d547c | ||
|
|
5a13f12334 | ||
|
|
b0a3b1f18e | ||
|
|
32c07d1b61 | ||
|
|
5d05b021aa | ||
|
|
4ac62d99e0 | ||
|
|
4ebb2dac2d | ||
|
|
52a6f5e048 |
@@ -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
|
||||||
|
|||||||
@@ -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,
|
||||||
) {
|
) {
|
||||||
|
|||||||
@@ -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(),
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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,
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -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()?;
|
||||||
|
|||||||
213
crates/wzp-client/src/encrypted_transport.rs
Normal file
213
crates/wzp-client/src/encrypted_transport.rs
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
));
|
));
|
||||||
|
|
||||||
|
|||||||
@@ -101,12 +101,15 @@ 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),
|
||||||
.await
|
transport.recv_signal(),
|
||||||
.map_err(HandshakeError::Transport)?
|
)
|
||||||
.ok_or(HandshakeError::ConnectionClosed)?;
|
.await
|
||||||
|
.map_err(|_| HandshakeError::Transport(wzp_proto::TransportError::Timeout { ms: 10_000 }))?
|
||||||
|
.map_err(HandshakeError::Transport)?
|
||||||
|
.ok_or(HandshakeError::ConnectionClosed)?;
|
||||||
|
|
||||||
let (callee_identity_pub, callee_ephemeral_pub, callee_signature, _chosen_profile) =
|
let (callee_identity_pub, callee_ephemeral_pub, callee_signature, _chosen_profile) =
|
||||||
match answer {
|
match answer {
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|
||||||
|
|||||||
@@ -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();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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();
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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]
|
||||||
|
|||||||
@@ -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>;
|
||||||
|
|||||||
@@ -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,
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -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);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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]
|
||||||
|
|||||||
@@ -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)"
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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()?;
|
||||||
|
|||||||
@@ -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,
|
||||||
.await
|
"connect:handshake_start",
|
||||||
.map_err(|e| {
|
serde_json::json!({
|
||||||
error!("perform_handshake failed: {e}");
|
"t_ms": call_t0.elapsed().as_millis(),
|
||||||
e
|
"room": room,
|
||||||
})?;
|
"remote": transport.remote_address().to_string(),
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
let _session = match wzp_client::handshake::perform_handshake(
|
||||||
|
&*transport,
|
||||||
|
&seed.0,
|
||||||
|
Some(&alias),
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
{
|
||||||
|
Ok(session) => session,
|
||||||
|
Err(e) => {
|
||||||
|
error!("perform_handshake failed: {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(
|
||||||
tracing::warn!("set_audio_mode_communication failed: {e}");
|
&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}");
|
||||||
|
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(),
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
192
docs/bugs/001-android-join-voice-hang.md
Normal file
192
docs/bugs/001-android-join-voice-hang.md
Normal 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:404–423`)
|
||||||
|
|
||||||
|
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 246–247).
|
||||||
|
|
||||||
|
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:404–423`: 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:576–635` — `CallEngine::start` audio section
|
||||||
|
- `desktop/src/main.ts:328–360` — `joinVoiceBtn` click handler
|
||||||
|
- `crates/wzp-client/src/handshake.rs:105` — handshake timeout
|
||||||
122
scripts/android-build-async.sh
Executable file
122
scripts/android-build-async.sh
Executable 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
|
||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
Reference in New Issue
Block a user