14 Commits

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-25 06:49:05 +04:00
10 changed files with 678 additions and 26 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

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

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

View File

@@ -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"
} }

View File

@@ -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