Compare commits
14 Commits
5d05b021aa
...
experiment
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
01f55caa96 | ||
|
|
0f93a2b745 | ||
|
|
2b93bd4b45 | ||
|
|
bc021517c0 | ||
|
|
739bdaf3ab | ||
|
|
bc1668ed96 | ||
|
|
77b036439b | ||
|
|
0ebc73ab13 | ||
|
|
394987a349 | ||
|
|
2aa6582585 | ||
|
|
ca987d547c | ||
|
|
5a13f12334 | ||
|
|
b0a3b1f18e | ||
|
|
32c07d1b61 |
@@ -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 {
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -186,6 +186,11 @@ tmux send-keys -t $TMUX_SESSION "cd $BINARY_DIR && ./wzp-relay \$RELAY_ARGS" Ent
|
|||||||
echo "Deploy done on $TARGET"
|
echo "Deploy done on $TARGET"
|
||||||
DEPLOY
|
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"
|
log "Deployed to $TARGET"
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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