Compare commits
16 Commits
4ebb2dac2d
...
experiment
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
01f55caa96 | ||
|
|
0f93a2b745 | ||
|
|
2b93bd4b45 | ||
|
|
bc021517c0 | ||
|
|
739bdaf3ab | ||
|
|
bc1668ed96 | ||
|
|
77b036439b | ||
|
|
0ebc73ab13 | ||
|
|
394987a349 | ||
|
|
2aa6582585 | ||
|
|
ca987d547c | ||
|
|
5a13f12334 | ||
|
|
b0a3b1f18e | ||
|
|
32c07d1b61 | ||
|
|
5d05b021aa | ||
|
|
4ac62d99e0 |
@@ -99,12 +99,12 @@ pub fn signal_to_call_type(signal: &SignalMessage) -> CallSignalType {
|
||||
SignalMessage::LossRecoveryUpdate { .. } => CallSignalType::Offer, // reuse (telemetry)
|
||||
SignalMessage::Ping { .. } | SignalMessage::Pong { .. } => CallSignalType::Offer,
|
||||
SignalMessage::AuthToken { .. } => CallSignalType::Offer,
|
||||
SignalMessage::Hold => CallSignalType::Hold,
|
||||
SignalMessage::Unhold => CallSignalType::Unhold,
|
||||
SignalMessage::Mute => CallSignalType::Mute,
|
||||
SignalMessage::Unmute => CallSignalType::Unmute,
|
||||
SignalMessage::Hold { .. } => CallSignalType::Hold,
|
||||
SignalMessage::Unhold { .. } => CallSignalType::Unhold,
|
||||
SignalMessage::Mute { .. } => CallSignalType::Mute,
|
||||
SignalMessage::Unmute { .. } => CallSignalType::Unmute,
|
||||
SignalMessage::Transfer { .. } => CallSignalType::Transfer,
|
||||
SignalMessage::TransferAck => CallSignalType::Offer, // reuse
|
||||
SignalMessage::TransferAck { .. } => CallSignalType::Offer, // reuse
|
||||
SignalMessage::PresenceUpdate { .. } => CallSignalType::Offer, // reuse
|
||||
SignalMessage::RouteQuery { .. } => CallSignalType::Offer, // reuse
|
||||
SignalMessage::TransportFeedback { .. } => CallSignalType::Offer, // reuse (BWE)
|
||||
@@ -199,19 +199,19 @@ mod tests {
|
||||
));
|
||||
|
||||
assert!(matches!(
|
||||
signal_to_call_type(&SignalMessage::Hold),
|
||||
signal_to_call_type(&SignalMessage::Hold { version: default_signal_version() }),
|
||||
CallSignalType::Hold
|
||||
));
|
||||
assert!(matches!(
|
||||
signal_to_call_type(&SignalMessage::Unhold),
|
||||
signal_to_call_type(&SignalMessage::Unhold { version: default_signal_version() }),
|
||||
CallSignalType::Unhold
|
||||
));
|
||||
assert!(matches!(
|
||||
signal_to_call_type(&SignalMessage::Mute),
|
||||
signal_to_call_type(&SignalMessage::Mute { version: default_signal_version() }),
|
||||
CallSignalType::Mute
|
||||
));
|
||||
assert!(matches!(
|
||||
signal_to_call_type(&SignalMessage::Unmute),
|
||||
signal_to_call_type(&SignalMessage::Unmute { version: default_signal_version() }),
|
||||
CallSignalType::Unmute
|
||||
));
|
||||
|
||||
|
||||
@@ -101,12 +101,15 @@ pub async fn perform_handshake(
|
||||
.await
|
||||
.map_err(HandshakeError::Transport)?;
|
||||
|
||||
// 5. Wait for CallAnswer
|
||||
let answer = transport
|
||||
.recv_signal()
|
||||
.await
|
||||
.map_err(HandshakeError::Transport)?
|
||||
.ok_or(HandshakeError::ConnectionClosed)?;
|
||||
// 5. Wait for CallAnswer — 10s timeout guards against relay not responding.
|
||||
let answer = tokio::time::timeout(
|
||||
std::time::Duration::from_secs(10),
|
||||
transport.recv_signal(),
|
||||
)
|
||||
.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) =
|
||||
match answer {
|
||||
|
||||
@@ -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);
|
||||
int poll_count = 0;
|
||||
bool streams_started = false;
|
||||
while (std::chrono::steady_clock::now() < deadline) {
|
||||
auto cap_state = g_capture_stream->getState();
|
||||
auto play_state = g_playout_stream->getState();
|
||||
if (cap_state == oboe::StreamState::Started &&
|
||||
play_state == oboe::StreamState::Started) {
|
||||
LOGI("both streams Started after %d polls", poll_count);
|
||||
streams_started = true;
|
||||
break;
|
||||
}
|
||||
poll_count++;
|
||||
@@ -420,6 +422,18 @@ int wzp_oboe_start(const WzpOboeConfig* config, const WzpOboeRings* rings) {
|
||||
(int)g_capture_stream->getState(),
|
||||
(int)g_playout_stream->getState(),
|
||||
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",
|
||||
|
||||
@@ -669,13 +669,25 @@ pub enum SignalMessage {
|
||||
},
|
||||
|
||||
/// Put the call on hold (stop sending media, keep session alive).
|
||||
Hold,
|
||||
Hold {
|
||||
#[serde(default = "default_signal_version")]
|
||||
version: u8,
|
||||
},
|
||||
/// 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,
|
||||
Mute {
|
||||
#[serde(default = "default_signal_version")]
|
||||
version: u8,
|
||||
},
|
||||
/// 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 {
|
||||
#[serde(default = "default_signal_version")]
|
||||
@@ -685,7 +697,10 @@ pub enum SignalMessage {
|
||||
relay_addr: Option<String>,
|
||||
},
|
||||
/// Acknowledge a transfer request.
|
||||
TransferAck,
|
||||
TransferAck {
|
||||
#[serde(default = "default_signal_version")]
|
||||
version: u8,
|
||||
},
|
||||
|
||||
/// Presence update from a peer relay (gossip protocol).
|
||||
/// Sent periodically over probe connections to share which fingerprints
|
||||
@@ -1729,7 +1744,7 @@ mod tests {
|
||||
version: default_signal_version(),
|
||||
timestamp_ms: 12345,
|
||||
},
|
||||
SignalMessage::Hold,
|
||||
SignalMessage::Hold { version: default_signal_version() },
|
||||
SignalMessage::Hangup {
|
||||
version: default_signal_version(),
|
||||
reason: HangupReason::Normal,
|
||||
@@ -1750,28 +1765,28 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
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 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 decoded: SignalMessage = serde_json::from_str(&json).unwrap();
|
||||
assert!(matches!(decoded, SignalMessage::Unhold));
|
||||
assert!(matches!(decoded, SignalMessage::Unhold { .. }));
|
||||
}
|
||||
|
||||
#[test]
|
||||
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 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 decoded: SignalMessage = serde_json::from_str(&json).unwrap();
|
||||
assert!(matches!(decoded, SignalMessage::Unmute));
|
||||
assert!(matches!(decoded, SignalMessage::Unmute { .. }));
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -1818,10 +1833,10 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
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 decoded: SignalMessage = serde_json::from_str(&json).unwrap();
|
||||
assert!(matches!(decoded, SignalMessage::TransferAck));
|
||||
assert!(matches!(decoded, SignalMessage::TransferAck { .. }));
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -10,13 +10,12 @@ bytes = { workspace = true }
|
||||
tracing = { workspace = true }
|
||||
wzp-proto = { path = "../wzp-proto" }
|
||||
|
||||
# AV1 SW codecs do not support Android target (build.rs panics on
|
||||
# aarch64-linux-android). Android uses MediaCodec for AV1 instead.
|
||||
[target.'cfg(not(target_os = "android"))'.dependencies]
|
||||
# AV1 SW codecs: shiguredo crates download prebuilt binaries at build time.
|
||||
# Prebuilts are available for macOS only; Android uses MediaCodec; Linux will
|
||||
# use system/vendored libs when that path is wired up (TODO).
|
||||
[target.'cfg(target_os = "macos")'.dependencies]
|
||||
shiguredo_dav1d = "2026.1.0"
|
||||
shiguredo_svt_av1 = "2026.1.0"
|
||||
|
||||
[target.'cfg(target_os = "macos")'.dependencies]
|
||||
shiguredo_video_toolbox = "2026.1"
|
||||
|
||||
[target.'cfg(target_os = "android")'.dependencies]
|
||||
|
||||
@@ -11,7 +11,7 @@ use crate::encoder::{VideoEncoder, VideoError};
|
||||
/// **Encoder dispatch:**
|
||||
/// - `H264Baseline` → `VideoToolboxEncoder` (macOS) / `MediaCodecEncoder` (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`].
|
||||
pub fn create_video_encoder(
|
||||
@@ -78,10 +78,15 @@ pub fn create_video_encoder(
|
||||
#[allow(clippy::needless_return)]
|
||||
return Err(VideoError::NotInitialized);
|
||||
}
|
||||
#[cfg(not(target_os = "android"))]
|
||||
#[cfg(target_os = "macos")]
|
||||
{
|
||||
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())),
|
||||
}
|
||||
@@ -92,7 +97,7 @@ pub fn create_video_encoder(
|
||||
/// **Decoder dispatch:**
|
||||
/// - `H264Baseline` → `VideoToolboxDecoder` (macOS) / `MediaCodecDecoder` (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`].
|
||||
pub fn create_video_decoder(
|
||||
@@ -154,10 +159,15 @@ pub fn create_video_decoder(
|
||||
return crate::mediacodec::MediaCodecAv1Decoder::new(width, height)
|
||||
.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()?))
|
||||
}
|
||||
#[cfg(not(any(target_os = "macos", target_os = "android")))]
|
||||
{
|
||||
let _ = (width, height);
|
||||
Err(VideoError::NotInitialized)
|
||||
}
|
||||
}
|
||||
_ => Err(VideoError::InvalidInput("not a video codec".into())),
|
||||
}
|
||||
@@ -170,30 +180,24 @@ mod tests {
|
||||
#[test]
|
||||
fn av1_encoder_factory_creates_svt_av1() {
|
||||
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!(
|
||||
matches!(enc, Err(VideoError::NotInitialized)),
|
||||
"AV1 SW encoder is unavailable on Android (no shiguredo_svt_av1)"
|
||||
);
|
||||
#[cfg(not(target_os = "android"))]
|
||||
assert!(
|
||||
enc.is_ok(),
|
||||
"AV1 encoder factory should succeed on non-Android platforms"
|
||||
"AV1 SW encoder is unavailable on Android/Linux (no shiguredo_svt_av1)"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn av1_decoder_factory_creates_decoder() {
|
||||
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!(
|
||||
matches!(dec, Err(VideoError::NotInitialized)),
|
||||
"AV1 decoder requires MediaCodec on Android; non-Android device returns NotInitialized"
|
||||
);
|
||||
#[cfg(not(target_os = "android"))]
|
||||
assert!(
|
||||
dec.is_ok(),
|
||||
"AV1 decoder factory should succeed on non-Android (dav1d SW fallback)"
|
||||
"AV1 decoder unavailable on Android/Linux (no shiguredo_dav1d)"
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
|
||||
pub mod av1_obu;
|
||||
pub mod controller;
|
||||
#[cfg(not(target_os = "android"))]
|
||||
#[cfg(target_os = "macos")]
|
||||
pub mod dav1d;
|
||||
pub mod decoder;
|
||||
pub mod depacketizer;
|
||||
@@ -17,13 +17,13 @@ pub mod framer;
|
||||
pub mod mediacodec;
|
||||
pub mod nack;
|
||||
pub mod simulcast;
|
||||
#[cfg(not(target_os = "android"))]
|
||||
#[cfg(target_os = "macos")]
|
||||
pub mod svt_av1;
|
||||
pub mod videotoolbox;
|
||||
|
||||
pub use av1_obu::{Av1Depacketizer, Av1ObuFramer, is_keyframe_obu};
|
||||
pub use controller::{VideoQualityController, VideoTarget};
|
||||
#[cfg(not(target_os = "android"))]
|
||||
#[cfg(target_os = "macos")]
|
||||
pub use dav1d::Dav1dDecoder;
|
||||
pub use decoder::VideoDecoder;
|
||||
pub use depacketizer::H264Depacketizer;
|
||||
@@ -37,7 +37,7 @@ pub use mediacodec::{
|
||||
};
|
||||
pub use nack::{CachedPacket, NackAction, NackReceiver, NackSender};
|
||||
pub use simulcast::{LayerPacket, LayerTarget, SimulcastEncoder, SimulcastLayer};
|
||||
#[cfg(not(target_os = "android"))]
|
||||
#[cfg(target_os = "macos")]
|
||||
pub use svt_av1::SvtAv1Encoder;
|
||||
pub use videotoolbox::{
|
||||
VideoToolboxAv1Decoder, VideoToolboxDecoder, VideoToolboxEncoder, VideoToolboxHevcDecoder,
|
||||
|
||||
@@ -56,6 +56,30 @@ fn audio_manager<'local>(
|
||||
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.
|
||||
/// This tells the audio policy to route through the communication device
|
||||
/// 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(())
|
||||
}
|
||||
|
||||
/// 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.
|
||||
pub fn set_audio_mode_normal() -> Result<(), String> {
|
||||
let (vm, activity) = jvm_and_activity()?;
|
||||
|
||||
@@ -133,6 +133,7 @@ fn codec_to_profile(codec: CodecId) -> QualityProfile {
|
||||
/// Handles RoomUpdate (participant list), QualityDirective (relay-pushed
|
||||
/// codec switch), and Hangup from the relay signal stream.
|
||||
async fn run_signal_task(
|
||||
app: tauri::AppHandle,
|
||||
transport: Arc<wzp_transport::QuinnTransport>,
|
||||
running: Arc<AtomicBool>,
|
||||
pending_profile: Arc<AtomicU8>,
|
||||
@@ -164,7 +165,32 @@ async fn run_signal_task(
|
||||
})
|
||||
.collect();
|
||||
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;
|
||||
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"));
|
||||
}
|
||||
Ok(Ok(Some(wzp_proto::SignalMessage::QualityDirective {
|
||||
@@ -544,13 +570,43 @@ impl CallEngine {
|
||||
// through the signal channel (DirectCallOffer/Answer carry
|
||||
// identity_pub + ephemeral_pub + signature).
|
||||
if !is_direct_p2p {
|
||||
let _session =
|
||||
wzp_client::handshake::perform_handshake(&*transport, &seed.0, Some(&alias))
|
||||
.await
|
||||
.map_err(|e| {
|
||||
error!("perform_handshake failed: {e}");
|
||||
e
|
||||
})?;
|
||||
crate::emit_call_debug(
|
||||
&app,
|
||||
"connect:handshake_start",
|
||||
serde_json::json!({
|
||||
"t_ms": call_t0.elapsed().as_millis(),
|
||||
"room": room,
|
||||
"remote": transport.remote_address().to_string(),
|
||||
}),
|
||||
);
|
||||
let _session = match wzp_client::handshake::perform_handshake(
|
||||
&*transport,
|
||||
&seed.0,
|
||||
Some(&alias),
|
||||
)
|
||||
.await
|
||||
{
|
||||
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!(
|
||||
t_ms = call_t0.elapsed().as_millis(),
|
||||
"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)"
|
||||
);
|
||||
}
|
||||
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
|
||||
// startup. `wzp_native::audio_start()` brings up the capture +
|
||||
// playout streams; send/recv tasks below pull/push PCM through
|
||||
// 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!(
|
||||
"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
|
||||
// started), we get the same "fresh rebuild" behavior on
|
||||
// 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::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
|
||||
// settle after the stop. 50ms is enough for the driver to
|
||||
// release the audio session; shorter risks the new start
|
||||
@@ -596,13 +684,76 @@ impl CallEngine {
|
||||
// (music drops from BT A2DP to earpiece, etc.).
|
||||
#[cfg(target_os = "android")]
|
||||
{
|
||||
if let Err(e) = crate::android_audio::set_audio_mode_communication() {
|
||||
tracing::warn!("set_audio_mode_communication failed: {e}");
|
||||
crate::emit_call_debug(
|
||||
&app,
|
||||
"connect:audio_mode_start",
|
||||
serde_json::json!({ "t_ms": call_t0.elapsed().as_millis() }),
|
||||
);
|
||||
match crate::android_audio::set_audio_mode_communication_on_main(app.clone()).await {
|
||||
Ok(()) => crate::emit_call_debug(
|
||||
&app,
|
||||
"connect:audio_mode_done",
|
||||
serde_json::json!({ "t_ms": call_t0.elapsed().as_millis() }),
|
||||
),
|
||||
Err(e) => {
|
||||
tracing::warn!("set_audio_mode_communication failed: {e}");
|
||||
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();
|
||||
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!(
|
||||
"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),
|
||||
"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 mic_muted = Arc::new(AtomicBool::new(false));
|
||||
@@ -1285,6 +1444,7 @@ impl CallEngine {
|
||||
// Signal task (presence + quality directives).
|
||||
let event_cb = Arc::new(event_cb);
|
||||
tokio::spawn(run_signal_task(
|
||||
app.clone(),
|
||||
transport.clone(),
|
||||
running.clone(),
|
||||
pending_profile.clone(),
|
||||
@@ -1693,6 +1853,7 @@ impl CallEngine {
|
||||
// Signal task (presence + quality directives)
|
||||
let event_cb = Arc::new(event_cb);
|
||||
tokio::spawn(run_signal_task(
|
||||
_app.clone(),
|
||||
transport.clone(),
|
||||
running.clone(),
|
||||
pending_profile.clone(),
|
||||
|
||||
@@ -59,13 +59,15 @@ fn set_call_debug_logs_internal(on: bool) {
|
||||
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
|
||||
/// regardless of the flag — the toggle only controls the GUI
|
||||
/// overlay, not the underlying Android log stream.
|
||||
/// regardless of the flag. Connect/register steps are always emitted
|
||||
/// 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) {
|
||||
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;
|
||||
}
|
||||
let payload = serde_json::json!({
|
||||
@@ -772,6 +774,18 @@ async fn connect(
|
||||
if reuse_endpoint.is_some() && pre_connected_transport.is_none() {
|
||||
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();
|
||||
// Log transport details for debugging direct P2P media issues
|
||||
|
||||
@@ -166,9 +166,57 @@ function getRelay(): RelayServer | null {
|
||||
let myFingerprint = "";
|
||||
let statusInterval: number | null = null;
|
||||
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 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)
|
||||
interface LobbyUser {
|
||||
fingerprint: string;
|
||||
@@ -186,6 +234,7 @@ const CALL_DEBUG_MAX = 200;
|
||||
listen("call-debug-log", (event: any) => {
|
||||
const entry: CallDebugEntry = event.payload;
|
||||
callDebugBuffer.push(entry);
|
||||
if (entry.step?.startsWith("connect:")) lastConnectDebug = entry;
|
||||
if (callDebugBuffer.length > CALL_DEBUG_MAX) callDebugBuffer.shift();
|
||||
renderCallDebugLog();
|
||||
});
|
||||
@@ -309,12 +358,16 @@ ctxCallBtn.addEventListener("click", async () => {
|
||||
|
||||
// ── Voice join/leave (drawer-based) ───────────────────────────────
|
||||
joinVoiceBtn.addEventListener("click", async () => {
|
||||
if (inVoice) return;
|
||||
if (inVoice || connectPending) return;
|
||||
const relay = getRelay();
|
||||
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 {
|
||||
await invoke("connect", {
|
||||
await connectWithTimeout({
|
||||
relay: relay.address,
|
||||
room: s.room || "general",
|
||||
alias: s.alias || "",
|
||||
@@ -324,6 +377,11 @@ joinVoiceBtn.addEventListener("click", async () => {
|
||||
enterVoice(false);
|
||||
} catch (e: any) {
|
||||
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");
|
||||
// Auto-connect to the call
|
||||
(async () => {
|
||||
if (connectPending) return;
|
||||
connectPending = true;
|
||||
const s = loadSettings();
|
||||
try {
|
||||
await invoke("connect", {
|
||||
await connectWithTimeout({
|
||||
relay: data.relay_addr,
|
||||
room: data.room,
|
||||
alias: s.alias || "",
|
||||
@@ -498,6 +558,9 @@ listen("signal-event", (event: any) => {
|
||||
enterVoice(true);
|
||||
} catch (e: any) {
|
||||
console.error("connect failed:", e);
|
||||
showToast(`Call failed to connect: ${errorMessage(e)}`);
|
||||
} finally {
|
||||
connectPending = false;
|
||||
}
|
||||
})();
|
||||
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
|
||||
@@ -105,20 +105,15 @@ docker run --rm --user 1000:1000 \
|
||||
set -euo pipefail
|
||||
cd /build/source
|
||||
|
||||
echo ">>> Building relay + client + web + bench..."
|
||||
cargo build --release --bin wzp-relay --bin wzp-client --bin wzp-web --bin wzp-bench 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 ">>> Building relay + web..."
|
||||
cargo build --release --bin wzp-relay --bin wzp-web 2>&1 | tail -5
|
||||
|
||||
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..."
|
||||
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"
|
||||
'
|
||||
@@ -131,7 +126,7 @@ TARBALL="$BASE_DIR/data/cache-linux/target/release/../../../wzp-linux-x86_64.tar
|
||||
docker run --rm \
|
||||
-v "$BASE_DIR/data/cache-linux/target:/build/target" \
|
||||
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
|
||||
|
||||
URL=$(curl -s -F "file=@/tmp/wzp-linux-x86_64.tar.gz" -H "Authorization: $rusty_auth_token" "$rusty_address")
|
||||
@@ -191,6 +186,11 @@ tmux send-keys -t $TMUX_SESSION "cd $BINARY_DIR && ./wzp-relay \$RELAY_ARGS" Ent
|
||||
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"
|
||||
}
|
||||
|
||||
|
||||
@@ -15,8 +15,8 @@ set -euo pipefail
|
||||
# - Output: desktop/src-tauri/gen/android/.../*.apk
|
||||
#
|
||||
# Usage:
|
||||
# ./scripts/build-tauri-android.sh # full pipeline (debug, arm64 only)
|
||||
# ./scripts/build-tauri-android.sh --release # release APK
|
||||
# ./scripts/build-tauri-android.sh # full pipeline (release, arm64 only)
|
||||
# ./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 --rust # force-clean rust target
|
||||
# ./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
|
||||
DO_PULL=1
|
||||
DO_INIT=0
|
||||
BUILD_RELEASE=0
|
||||
BUILD_RELEASE=1
|
||||
BUILD_ARCH="arm64"
|
||||
NEXT_IS_ARCH=0
|
||||
for arg in "$@"; do
|
||||
@@ -52,7 +52,7 @@ for arg in "$@"; do
|
||||
--pull) DO_PULL=1 ;;
|
||||
--no-pull) DO_PULL=0 ;;
|
||||
--init) DO_INIT=1 ;;
|
||||
--release) BUILD_RELEASE=1 ;;
|
||||
--debug) BUILD_RELEASE=0 ;;
|
||||
--arch) NEXT_IS_ARCH=1 ;;
|
||||
-h|--help)
|
||||
sed -n '3,32p' "$0"
|
||||
@@ -321,6 +321,31 @@ for ARCH in $ARCHS; do
|
||||
echo ">>> 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
|
||||
BUILT_APK=$(find gen/android -name "*.apk" -newer "$APK_OUTPUT_DIR" -type f 2>/dev/null | head -1)
|
||||
if [ -z "$BUILT_APK" ]; then
|
||||
|
||||
Reference in New Issue
Block a user