feat(android): Bluetooth audio routing + network change detection + per-arch APK builds

Bluetooth: wire existing AudioRouteManager SCO support through both app
variants. Replace binary speaker toggle with 3-way route cycling
(Earpiece → Speaker → Bluetooth). Tauri side adds JNI bridge functions
(start/stop/query SCO, device availability) and Oboe stream restart.

Network awareness: integrate Android ConnectivityManager to detect
WiFi/cellular transitions and feed them to AdaptiveQualityController
via lock-free AtomicU8 signaling. Enables proactive quality downgrade
and FEC boost on network handoffs.

Build: add --arch flag to build-tauri-android.sh supporting arm64,
armv7, or all (separate per-arch APKs for smaller tester binaries).

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Siavash Sameni
2026-04-12 16:07:41 +04:00
parent 29cd23fe39
commit 4c1ad841e1
15 changed files with 1050 additions and 105 deletions

View File

@@ -96,6 +96,17 @@ class WzpEngine(private val callback: WzpCallback) {
if (nativeHandle != 0L) nativeForceProfile(nativeHandle, profile)
}
/**
* Signal a network transport change (e.g. WiFi → LTE handoff).
*
* @param networkType matches Rust `NetworkContext` ordinals:
* 0=WiFi, 1=LTE, 2=5G, 3=3G, 4=Unknown, 5=None
* @param bandwidthKbps reported downstream bandwidth in kbps
*/
fun onNetworkChanged(networkType: Int, bandwidthKbps: Int) {
if (nativeHandle != 0L) nativeOnNetworkChanged(nativeHandle, networkType, bandwidthKbps)
}
/** Destroy the native engine and free all resources. The instance must not be reused. */
@Synchronized
fun destroy() {
@@ -163,6 +174,7 @@ class WzpEngine(private val callback: WzpCallback) {
private external fun nativeStartSignaling(handle: Long, relay: String, seed: String, token: String, alias: String): Int
private external fun nativePlaceCall(handle: Long, targetFp: String): Int
private external fun nativeAnswerCall(handle: Long, callId: String, mode: Int): Int
private external fun nativeOnNetworkChanged(handle: Long, networkType: Int, bandwidthKbps: Int)
/**
* Ping a relay server. Requires engine to be initialized.

View File

@@ -0,0 +1,141 @@
package com.wzp.net
import android.content.Context
import android.net.ConnectivityManager
import android.net.Network
import android.net.NetworkCapabilities
import android.net.NetworkRequest
import android.os.Handler
import android.os.Looper
/**
* Monitors network connectivity changes via [ConnectivityManager.NetworkCallback]
* and classifies the active transport (WiFi, LTE, 5G, 3G).
*
* Callbacks fire on the main looper so callers can safely update UI state or
* dispatch to a native engine from any callback.
*
* Usage:
* 1. Set [onNetworkChanged] to receive `(type: Int, downlinkKbps: Int)` events
* 2. Optionally set [onIpChanged] for IP address change events (mid-call ICE refresh)
* 3. Call [register] when the call starts
* 4. Call [unregister] when the call ends
*/
class NetworkMonitor(context: Context) {
private val cm = context.getSystemService(Context.CONNECTIVITY_SERVICE) as ConnectivityManager
private val mainHandler = Handler(Looper.getMainLooper())
/**
* Called when the network transport type or bandwidth changes.
* `type` constants match the Rust `NetworkContext` enum ordinals.
*/
var onNetworkChanged: ((type: Int, downlinkKbps: Int) -> Unit)? = null
/**
* Called when the device's IP address changes (link properties changed).
* Useful for triggering mid-call ICE candidate re-gathering.
*/
var onIpChanged: (() -> Unit)? = null
// Track the last emitted type to avoid redundant callbacks
@Volatile
private var lastEmittedType: Int = TYPE_UNKNOWN
private val callback = object : ConnectivityManager.NetworkCallback() {
override fun onAvailable(network: Network) {
classifyAndEmit(network)
}
override fun onCapabilitiesChanged(network: Network, caps: NetworkCapabilities) {
classifyFromCaps(caps)
}
override fun onLinkPropertiesChanged(
network: Network,
linkProperties: android.net.LinkProperties
) {
// IP address may have changed — notify for ICE refresh
onIpChanged?.invoke()
// Also re-classify in case the transport changed simultaneously
classifyAndEmit(network)
}
override fun onLost(network: Network) {
lastEmittedType = TYPE_NONE
onNetworkChanged?.invoke(TYPE_NONE, 0)
}
}
// -- Public API -----------------------------------------------------------
/** Register the network callback. Call when a call starts. */
fun register() {
val request = NetworkRequest.Builder()
.addCapability(NetworkCapabilities.NET_CAPABILITY_INTERNET)
.build()
cm.registerNetworkCallback(request, callback, mainHandler)
}
/** Unregister the network callback. Call when the call ends. */
fun unregister() {
try {
cm.unregisterNetworkCallback(callback)
} catch (_: IllegalArgumentException) {
// Already unregistered — safe to ignore
}
}
// -- Classification -------------------------------------------------------
private fun classifyAndEmit(network: Network) {
val caps = cm.getNetworkCapabilities(network) ?: return
classifyFromCaps(caps)
}
private fun classifyFromCaps(caps: NetworkCapabilities) {
val type = when {
caps.hasTransport(NetworkCapabilities.TRANSPORT_WIFI) -> TYPE_WIFI
caps.hasTransport(NetworkCapabilities.TRANSPORT_ETHERNET) -> TYPE_WIFI // treat as WiFi
caps.hasTransport(NetworkCapabilities.TRANSPORT_CELLULAR) -> classifyCellular(caps)
else -> TYPE_UNKNOWN
}
val bw = caps.getLinkDownstreamBandwidthKbps()
// Deduplicate: only emit when the transport type actually changes
if (type != lastEmittedType) {
lastEmittedType = type
onNetworkChanged?.invoke(type, bw)
}
}
/**
* Approximate cellular generation from reported downstream bandwidth.
* This avoids requiring READ_PHONE_STATE permission (needed for
* TelephonyManager.getNetworkType on API 30+).
*
* Thresholds are conservative — carriers over-report bandwidth, so we
* classify based on what's actually usable for VoIP:
* - >= 100 Mbps → 5G NR
* - >= 10 Mbps → LTE
* - < 10 Mbps → 3G or worse
*/
private fun classifyCellular(caps: NetworkCapabilities): Int {
val bw = caps.getLinkDownstreamBandwidthKbps()
return when {
bw >= 100_000 -> TYPE_CELLULAR_5G
bw >= 10_000 -> TYPE_CELLULAR_LTE
else -> TYPE_CELLULAR_3G
}
}
companion object {
/** Constants matching Rust `NetworkContext` enum ordinals. */
const val TYPE_WIFI = 0
const val TYPE_CELLULAR_LTE = 1
const val TYPE_CELLULAR_5G = 2
const val TYPE_CELLULAR_3G = 3
const val TYPE_UNKNOWN = 4
const val TYPE_NONE = 5
}
}

View File

@@ -5,6 +5,7 @@ import android.util.Log
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.wzp.audio.AudioPipeline
import com.wzp.audio.AudioRoute
import com.wzp.audio.AudioRouteManager
import com.wzp.data.SettingsRepository
import com.wzp.debug.DebugReporter
@@ -12,6 +13,7 @@ import com.wzp.engine.CallStats
import com.wzp.service.CallService
import com.wzp.engine.WzpCallback
import com.wzp.engine.WzpEngine
import com.wzp.net.NetworkMonitor
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.delay
@@ -43,6 +45,7 @@ class CallViewModel : ViewModel(), WzpCallback {
private var engineInitialized = false
private var audioPipeline: AudioPipeline? = null
private var audioRouteManager: AudioRouteManager? = null
private var networkMonitor: NetworkMonitor? = null
private var audioStarted = false
private var appContext: Context? = null
private var settings: SettingsRepository? = null
@@ -60,6 +63,9 @@ class CallViewModel : ViewModel(), WzpCallback {
private val _isSpeaker = MutableStateFlow(false)
val isSpeaker: StateFlow<Boolean> = _isSpeaker.asStateFlow()
private val _audioRoute = MutableStateFlow(AudioRoute.EARPIECE)
val audioRoute: StateFlow<AudioRoute> = _audioRoute.asStateFlow()
private val _stats = MutableStateFlow(CallStats())
val stats: StateFlow<CallStats> = _stats.asStateFlow()
@@ -226,7 +232,19 @@ class CallViewModel : ViewModel(), WzpCallback {
audioPipeline = AudioPipeline(appCtx)
}
if (audioRouteManager == null) {
audioRouteManager = AudioRouteManager(appCtx)
audioRouteManager = AudioRouteManager(appCtx).also { arm ->
arm.onRouteChanged = { route ->
_audioRoute.value = route
_isSpeaker.value = (route == AudioRoute.SPEAKER)
}
}
}
if (networkMonitor == null) {
networkMonitor = NetworkMonitor(appCtx).also { nm ->
nm.onNetworkChanged = { type, bw ->
engine?.onNetworkChanged(type, bw)
}
}
}
if (debugReporter == null) {
debugReporter = DebugReporter(appCtx)
@@ -607,6 +625,27 @@ class CallViewModel : ViewModel(), WzpCallback {
audioRouteManager?.setSpeaker(newSpeaker)
}
/** Cycle audio output: Earpiece → Speaker → Bluetooth (if available) → Earpiece. */
fun cycleAudioRoute() {
val routes = audioRouteManager?.availableRoutes() ?: return
val currentIdx = routes.indexOf(_audioRoute.value)
val next = routes[(currentIdx + 1) % routes.size]
when (next) {
AudioRoute.EARPIECE -> {
audioRouteManager?.setBluetoothSco(false)
audioRouteManager?.setSpeaker(false)
}
AudioRoute.SPEAKER -> {
audioRouteManager?.setSpeaker(true)
}
AudioRoute.BLUETOOTH -> {
audioRouteManager?.setBluetoothSco(true)
}
}
_audioRoute.value = next
_isSpeaker.value = (next == AudioRoute.SPEAKER)
}
fun clearError() { _errorMessage.value = null }
fun sendDebugReport() {
@@ -661,6 +700,7 @@ class CallViewModel : ViewModel(), WzpCallback {
it.start(e)
}
audioRouteManager?.register()
networkMonitor?.register()
audioStarted = true
}
@@ -668,8 +708,10 @@ class CallViewModel : ViewModel(), WzpCallback {
if (!audioStarted) return
audioPipeline?.stop() // sets running=false; DON'T null — teardown needs awaitDrain()
audioRouteManager?.unregister()
networkMonitor?.unregister()
audioRouteManager?.setSpeaker(false)
_isSpeaker.value = false
_audioRoute.value = AudioRoute.EARPIECE
audioStarted = false
}

View File

@@ -49,6 +49,7 @@ import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import com.wzp.audio.AudioRoute
import com.wzp.engine.CallStats
import com.wzp.ui.components.CopyableFingerprint
import com.wzp.ui.components.Identicon
@@ -74,6 +75,7 @@ fun InCallScreen(
val callState by viewModel.callState.collectAsState()
val isMuted by viewModel.isMuted.collectAsState()
val isSpeaker by viewModel.isSpeaker.collectAsState()
val audioRoute by viewModel.audioRoute.collectAsState()
val stats by viewModel.stats.collectAsState()
val qualityTier by viewModel.qualityTier.collectAsState()
val errorMessage by viewModel.errorMessage.collectAsState()
@@ -621,12 +623,12 @@ fun InCallScreen(
Spacer(modifier = Modifier.height(16.dp))
// Controls: Mic / End / Spk
// Controls: Mic / End / Route (Ear/Spk/BT)
ControlRow(
isMuted = isMuted,
isSpeaker = isSpeaker,
audioRoute = audioRoute,
onToggleMute = viewModel::toggleMute,
onToggleSpeaker = viewModel::toggleSpeaker,
onCycleRoute = viewModel::cycleAudioRoute,
onHangUp = { viewModel.stopCall() }
)
@@ -915,9 +917,9 @@ private fun AudioLevelBar(audioLevel: Int) {
@Composable
private fun ControlRow(
isMuted: Boolean,
isSpeaker: Boolean,
audioRoute: AudioRoute,
onToggleMute: () -> Unit,
onToggleSpeaker: () -> Unit,
onCycleRoute: () -> Unit,
onHangUp: () -> Unit
) {
Row(
@@ -959,22 +961,28 @@ private fun ControlRow(
Text("End", style = MaterialTheme.typography.titleMedium.copy(fontWeight = FontWeight.Bold))
}
// Speaker
// Audio route: cycles Earpiece → Speaker → Bluetooth (when available)
FilledTonalIconButton(
onClick = onToggleSpeaker,
onClick = onCycleRoute,
modifier = Modifier.size(56.dp),
colors = if (isSpeaker) {
IconButtonDefaults.filledTonalIconButtonColors(
colors = when (audioRoute) {
AudioRoute.SPEAKER -> IconButtonDefaults.filledTonalIconButtonColors(
containerColor = Color(0xFF0F3460), contentColor = Color.White
)
} else {
IconButtonDefaults.filledTonalIconButtonColors(
AudioRoute.BLUETOOTH -> IconButtonDefaults.filledTonalIconButtonColors(
containerColor = Color(0xFF2563EB), contentColor = Color.White
)
else -> IconButtonDefaults.filledTonalIconButtonColors(
containerColor = DarkSurface2, contentColor = Color.White
)
}
) {
Text(
text = if (isSpeaker) "Spk\nOn" else "Spk",
text = when (audioRoute) {
AudioRoute.EARPIECE -> "Ear"
AudioRoute.SPEAKER -> "Spk"
AudioRoute.BLUETOOTH -> "BT"
},
textAlign = TextAlign.Center,
style = MaterialTheme.typography.labelSmall,
lineHeight = 12.sp

View File

@@ -99,6 +99,9 @@ pub(crate) struct EngineState {
/// QUIC transport handle — stored so stop_call() can close it immediately,
/// triggering relay-side leave + RoomUpdate broadcast.
pub quic_transport: Mutex<Option<Arc<wzp_transport::QuinnTransport>>>,
/// Network type from Android ConnectivityManager, polled by recv task.
/// 0xFF = no change pending; 0-5 = NetworkContext ordinal.
pub pending_network_type: AtomicU8,
}
pub struct WzpEngine {
@@ -120,6 +123,7 @@ impl WzpEngine {
playout_ring: AudioRing::new(),
audio_level_rms: AtomicU32::new(0),
quic_transport: Mutex::new(None),
pending_network_type: AtomicU8::new(PROFILE_NO_CHANGE),
});
Self {
state,
@@ -404,6 +408,13 @@ impl WzpEngine {
pub fn force_profile(&self, _profile: QualityProfile) {}
/// Signal a network transport change from Android ConnectivityManager.
/// Stores the type atomically; the recv task polls it on each packet.
pub fn on_network_changed(&self, network_type: u8, bandwidth_kbps: u32) {
info!(network_type, bandwidth_kbps, "on_network_changed");
self.state.pending_network_type.store(network_type, Ordering::Release);
}
pub fn get_stats(&self) -> CallStats {
let mut stats = self.state.stats.lock().unwrap().clone();
if let Some(start) = self.call_start {
@@ -871,6 +882,23 @@ async fn run_call(
);
}
// Check for network transport change from ConnectivityManager
{
let net = state.pending_network_type.swap(PROFILE_NO_CHANGE, Ordering::Acquire);
if net != PROFILE_NO_CHANGE {
use wzp_proto::NetworkContext;
let ctx = match net {
0 => NetworkContext::WiFi,
1 => NetworkContext::CellularLte,
2 => NetworkContext::Cellular5g,
3 => NetworkContext::Cellular3g,
_ => NetworkContext::Unknown,
};
quality_ctrl.signal_network_change(ctx);
info!(?ctx, "quality controller: network context updated");
}
}
// Adaptive quality: ingest quality reports from relay
if auto_profile {
if let Some(ref qr) = pkt.quality_report {

View File

@@ -222,6 +222,29 @@ pub unsafe extern "system" fn Java_com_wzp_engine_WzpEngine_nativeForceProfile(
}));
}
/// Signal a network transport change from the Android ConnectivityManager.
///
/// `network_type` matches the Rust `NetworkContext` enum:
/// 0=WiFi, 1=CellularLte, 2=Cellular5g, 3=Cellular3g, 4=Unknown, 5=None
///
/// The engine forwards this to the `AdaptiveQualityController` which:
/// - Preemptively downgrades one tier on WiFi→cellular
/// - Activates a 10-second FEC boost
/// - Uses faster downgrade thresholds on cellular
#[unsafe(no_mangle)]
pub unsafe extern "system" fn Java_com_wzp_engine_WzpEngine_nativeOnNetworkChanged(
_env: JNIEnv,
_class: JClass,
handle: jlong,
network_type: jint,
bandwidth_kbps: jint,
) {
let _ = panic::catch_unwind(panic::AssertUnwindSafe(|| {
let h = unsafe { handle_ref(handle) };
h.engine.on_network_changed(network_type as u8, bandwidth_kbps as u32);
}));
}
/// Write captured PCM samples from Kotlin AudioRecord into the engine's capture ring.
/// pcm is a Java short[] array.
#[unsafe(no_mangle)]

View File

@@ -96,3 +96,134 @@ pub fn is_speakerphone_on() -> Result<bool, String> {
.map_err(|e| format!("isSpeakerphoneOn: {e}"))?;
Ok(on)
}
// ─── Bluetooth SCO routing ──────────────────────────────────────────────────
/// Start Bluetooth SCO (Synchronous Connection Oriented) audio routing.
///
/// Turns off the loudspeaker, then opens the SCO link so both capture and
/// playout move to the connected Bluetooth headset. Requires that a SCO-
/// capable device is paired and connected (check [`is_bluetooth_available`]
/// first). The caller must restart Oboe streams after this call.
#[allow(deprecated)]
pub fn start_bluetooth_sco() -> Result<(), String> {
let (vm, activity) = jvm_and_activity()?;
let mut env = vm
.attach_current_thread()
.map_err(|e| format!("attach_current_thread: {e}"))?;
let am = audio_manager(&mut env, &activity)?;
// Ensure speaker is off — mutually exclusive with SCO.
env.call_method(
&am,
"setSpeakerphoneOn",
"(Z)V",
&[JValue::Bool(0)],
)
.map_err(|e| format!("setSpeakerphoneOn(false): {e}"))?;
env.call_method(&am, "startBluetoothSco", "()V", &[])
.map_err(|e| format!("startBluetoothSco: {e}"))?;
env.call_method(
&am,
"setBluetoothScoOn",
"(Z)V",
&[JValue::Bool(1)],
)
.map_err(|e| format!("setBluetoothScoOn(true): {e}"))?;
tracing::info!("AudioManager: Bluetooth SCO started");
Ok(())
}
/// Stop Bluetooth SCO audio routing, returning audio to the earpiece.
///
/// Safe to call even if SCO is not currently active (no-ops in that case).
/// The caller must restart Oboe streams after this call.
#[allow(deprecated)]
pub fn stop_bluetooth_sco() -> Result<(), String> {
let (vm, activity) = jvm_and_activity()?;
let mut env = vm
.attach_current_thread()
.map_err(|e| format!("attach_current_thread: {e}"))?;
let am = audio_manager(&mut env, &activity)?;
let is_on = env
.call_method(&am, "isBluetoothScoOn", "()Z", &[])
.and_then(|v| v.z())
.unwrap_or(false);
if is_on {
env.call_method(
&am,
"setBluetoothScoOn",
"(Z)V",
&[JValue::Bool(0)],
)
.map_err(|e| format!("setBluetoothScoOn(false): {e}"))?;
env.call_method(&am, "stopBluetoothSco", "()V", &[])
.map_err(|e| format!("stopBluetoothSco: {e}"))?;
}
tracing::info!(was_on = is_on, "AudioManager: Bluetooth SCO stopped");
Ok(())
}
/// Query whether Bluetooth SCO audio is currently active.
#[allow(deprecated)]
pub fn is_bluetooth_sco_on() -> 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 am = audio_manager(&mut env, &activity)?;
env.call_method(&am, "isBluetoothScoOn", "()Z", &[])
.and_then(|v| v.z())
.map_err(|e| format!("isBluetoothScoOn: {e}"))
}
/// Check whether a Bluetooth SCO-capable device is currently connected.
///
/// Iterates `AudioManager.getDevices(GET_DEVICES_OUTPUTS)` and looks for
/// `TYPE_BLUETOOTH_SCO` (7).
pub fn is_bluetooth_available() -> 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 am = audio_manager(&mut env, &activity)?;
// AudioManager.GET_DEVICES_OUTPUTS = 2
let devices = env
.call_method(
&am,
"getDevices",
"(I)[Landroid/media/AudioDeviceInfo;",
&[JValue::Int(2)],
)
.and_then(|v| v.l())
.map_err(|e| format!("getDevices(OUTPUTS): {e}"))?;
let arr = jni::objects::JObjectArray::from(devices);
let len = env
.get_array_length(&arr)
.map_err(|e| format!("get_array_length: {e}"))?;
for i in 0..len {
let device = env
.get_object_array_element(&arr, i)
.map_err(|e| format!("get_object_array_element({i}): {e}"))?;
let device_type = env
.call_method(&device, "getType", "()I", &[])
.and_then(|v| v.i())
.unwrap_or(0);
// TYPE_BLUETOOTH_SCO = 7
if device_type == 7 {
return Ok(true);
}
}
Ok(false)
}

View File

@@ -775,6 +775,71 @@ async fn is_speakerphone_on() -> Result<bool, String> {
}
}
// ─── Bluetooth SCO routing (Android-specific, no-op on desktop) ─────────────
/// Enable or disable Bluetooth SCO audio routing. Like speakerphone toggling,
/// this requires an Oboe stream restart so AAudio picks up the new route.
#[tauri::command]
#[allow(unused_variables)]
async fn set_bluetooth_sco(on: bool) -> Result<(), String> {
#[cfg(target_os = "android")]
{
if on {
android_audio::start_bluetooth_sco()?;
} else {
android_audio::stop_bluetooth_sco()?;
}
if wzp_native::is_loaded() && wzp_native::audio_is_running() {
tracing::info!(on, "set_bluetooth_sco: restarting Oboe for route change");
tokio::task::spawn_blocking(|| {
wzp_native::audio_stop();
wzp_native::audio_start()
.map_err(|code| format!("audio_start after BT toggle: code {code}"))
})
.await
.map_err(|e| format!("spawn_blocking join: {e}"))??;
tracing::info!("set_bluetooth_sco: Oboe restarted");
}
Ok(())
}
#[cfg(not(target_os = "android"))]
{
Ok(())
}
}
/// Check whether a Bluetooth SCO device is currently connected and available.
#[tauri::command]
async fn is_bluetooth_available() -> Result<bool, String> {
#[cfg(target_os = "android")]
{
android_audio::is_bluetooth_available()
}
#[cfg(not(target_os = "android"))]
{
Ok(false)
}
}
/// Return the current audio route as a string: "bluetooth", "speaker", or "earpiece".
#[tauri::command]
async fn get_audio_route() -> Result<String, String> {
#[cfg(target_os = "android")]
{
if android_audio::is_bluetooth_sco_on()? {
return Ok("bluetooth".into());
}
if android_audio::is_speakerphone_on()? {
return Ok("speaker".into());
}
Ok("earpiece".into())
}
#[cfg(not(target_os = "android"))]
{
Ok("earpiece".into())
}
}
// ─── Call history commands ───────────────────────────────────────────────────
#[tauri::command]
@@ -1892,6 +1957,7 @@ pub fn run() {
hangup_call,
deregister,
set_speakerphone, is_speakerphone_on,
set_bluetooth_sco, is_bluetooth_available, get_audio_route,
get_call_history, get_recent_contacts, clear_call_history,
set_dred_verbose_logs, get_dred_verbose_logs,
set_call_debug_logs, get_call_debug_logs,

View File

@@ -872,12 +872,11 @@ function showCallScreen() {
}
callStatus.className = "status-dot";
statusInterval = window.setInterval(pollStatus, 250);
// Sync the Speaker/Earpiece label with the OS state (Android only; on
// desktop the command is a no-op returning false so we land on "Earpiece"
// which is fine because desktop has no routing concept).
invoke<boolean>("is_speakerphone_on")
.then((on) => { speakerphoneOn = !!on; updateSpkLabel(); })
.catch(() => { speakerphoneOn = false; updateSpkLabel(); });
// Sync the audio route label with the OS state (Android only; on desktop
// get_audio_route returns "earpiece" so we land on the default).
invoke<string>("get_audio_route")
.then((route) => { currentAudioRoute = (route as AudioRoute) || "earpiece"; updateRouteLabel(); })
.catch(() => { currentAudioRoute = "earpiece"; updateRouteLabel(); });
}
function showConnectScreen() {
@@ -898,38 +897,74 @@ micBtn.addEventListener("click", async () => {
try { const m: boolean = await invoke("toggle_mic"); micBtn.classList.toggle("muted", m); micIcon.textContent = m ? "Mic Off" : "Mic"; } catch {}
});
// Speaker routing (Android) — toggles AudioManager.setSpeakerphoneOn + then
// stops and restarts the Oboe streams so AAudio reconfigures with the new
// routing. The Rust-side Tauri command handles the restart, we just swap
// the button label.
// Audio routing (Android) — cycles between earpiece, speaker, and Bluetooth
// SCO. Each transition calls the corresponding Tauri command which sets the
// AudioManager state and restarts Oboe streams so AAudio picks up the new
// route. On desktop all commands are no-ops.
//
// Earpiece is NOT a "muted" state, so DO NOT add the `.muted` CSS class
// (which would tint the button red); that was a bug in 0178cbd that made
// earpiece mode look like playback was off. A separate `.speaker-on` class
// is available for css styling if we want to visually indicate loud mode.
let speakerphoneOn = false;
let speakerphoneBusy = false;
function updateSpkLabel() {
spkBtn.classList.toggle("speaker-on", speakerphoneOn);
// earpiece mode look like playback was off.
type AudioRoute = "earpiece" | "speaker" | "bluetooth";
let currentAudioRoute: AudioRoute = "earpiece";
let routeBusy = false;
function updateRouteLabel() {
spkBtn.classList.remove("speaker-on", "bt-on");
spkBtn.classList.remove("muted");
spkIcon.textContent = speakerphoneOn ? "🔊 Speaker" : "🔈 Earpiece";
switch (currentAudioRoute) {
case "speaker":
spkIcon.textContent = "🔊 Speaker";
spkBtn.classList.add("speaker-on");
break;
case "bluetooth":
spkIcon.textContent = "🎧 BT";
spkBtn.classList.add("bt-on");
break;
default:
spkIcon.textContent = "🔈 Earpiece";
break;
}
}
spkBtn.addEventListener("click", async () => {
if (speakerphoneBusy) return; // debounce — the restart takes ~60ms
speakerphoneBusy = true;
const next = !speakerphoneOn;
async function cycleAudioRoute() {
if (routeBusy) return; // debounce — Oboe restart takes ~60-400ms
routeBusy = true;
spkBtn.disabled = true;
try {
await invoke("set_speakerphone", { on: next });
speakerphoneOn = next;
updateSpkLabel();
const btAvailable = await invoke<boolean>("is_bluetooth_available");
const routes: AudioRoute[] = btAvailable
? ["earpiece", "speaker", "bluetooth"]
: ["earpiece", "speaker"];
const idx = routes.indexOf(currentAudioRoute);
const next = routes[(idx + 1) % routes.length];
// Tear down current route
if (currentAudioRoute === "bluetooth") {
await invoke("set_bluetooth_sco", { on: false });
}
// Activate next route
if (next === "speaker") {
await invoke("set_speakerphone", { on: true });
} else if (next === "bluetooth") {
await invoke("set_speakerphone", { on: false });
await invoke("set_bluetooth_sco", { on: true });
} else {
// earpiece — turn everything off
await invoke("set_speakerphone", { on: false });
}
currentAudioRoute = next;
updateRouteLabel();
} catch (e) {
console.error("set_speakerphone failed:", e);
console.error("cycleAudioRoute failed:", e);
} finally {
spkBtn.disabled = false;
speakerphoneBusy = false;
routeBusy = false;
}
});
}
spkBtn.addEventListener("click", cycleAudioRoute);
hangupBtn.addEventListener("click", async () => {
userDisconnected = true;
// Use the new hangup_call command instead of raw disconnect —
@@ -1002,7 +1037,7 @@ async function pollStatus() {
micBtn.classList.toggle("muted", st.mic_muted);
micIcon.textContent = st.mic_muted ? "Mic Off" : "Mic";
// NB: spkBtn label is driven by the Android audio routing state
// (speakerphoneOn / updateSpkLabel), not by the engine's spk_muted.
// (currentAudioRoute / updateRouteLabel), not by the engine's spk_muted.
// Skip that here so pollStatus doesn't clobber the routing UI.
callTimer.textContent = formatDuration(st.call_duration_secs);

View File

@@ -1083,7 +1083,10 @@ button.primary:disabled { opacity: 0.5; cursor: not-allowed; }
color: white;
}
/* Speaker routing button (non-muted earpiece state should not look red) */
/* Audio routing button — highlight color depends on active route */
#spk-btn.speaker-on .icon {
color: var(--accent);
}
#spk-btn.bt-on .icon {
color: #60a5fa; /* blue-400 for Bluetooth */
}

View File

@@ -940,3 +940,55 @@ The patch introduces an `MSVC_CL` variable that is true only for real `cl.exe` (
This does not affect macOS or Linux builds — on those platforms `MSVC=0` everywhere so the patched logic behaves identically to upstream.
Upstream tracking: xiph/opus#256, xiph/opus PR #257 (both stale).
## Network Awareness (Android)
The adaptive quality controller (`AdaptiveQualityController` in `wzp-proto`) supports proactive network-aware adaptation via `signal_network_change(NetworkContext)`. On Android, this is fed by `NetworkMonitor.kt` which wraps `ConnectivityManager.NetworkCallback`.
```
ConnectivityManager
│ onCapabilitiesChanged / onLost
NetworkMonitor.kt ──classify──► type: Int (WiFi=0, LTE=1, 5G=2, 3G=3)
│ onNetworkChanged(type, bw)
CallViewModel ──► WzpEngine.onNetworkChanged()
│ JNI
jni_bridge.rs
EngineState.pending_network_type (AtomicU8, lock-free)
│ polled every ~20ms
recv task: quality_ctrl.signal_network_change(ctx)
├─ WiFi → Cellular: preemptive 1-tier downgrade
├─ Any change: 10s FEC boost (+0.2 ratio)
└─ Cellular: faster downgrade thresholds (2 vs 3)
```
Cellular generation is approximated from `getLinkDownstreamBandwidthKbps()` to avoid requiring `READ_PHONE_STATE` permission.
## Audio Routing (Android)
Both Android app variants support 3-way audio routing: **Earpiece → Speaker → Bluetooth SCO**.
### Native Kotlin App
`AudioRouteManager.kt` handles device detection (via `AudioDeviceCallback`), SCO lifecycle (`startBluetoothSco` / `stopBluetoothSco`), and auto-fallback on BT disconnect. `CallViewModel.cycleAudioRoute()` cycles through available routes.
### Tauri Desktop App
`android_audio.rs` provides JNI bridges to `AudioManager` for speakerphone and Bluetooth SCO control. After each route change, Oboe streams are stopped and restarted via `spawn_blocking` to force AAudio to reconfigure with the new routing.
```
User tap ──► cycleAudioRoute()
├─ Earpiece: setSpeakerphoneOn(false)
├─ Speaker: setSpeakerphoneOn(true)
└─ BT SCO: startBluetoothSco() + setBluetoothScoOn(true)
Oboe stop + start (~60-400ms)
```

View File

@@ -583,6 +583,49 @@ Signal messages are sent over reliable QUIC streams as length-prefixed JSON:
| wzp-client | 30 + 2 integration | Encoder/decoder, quality adapter, silence, drift, sweep |
| wzp-web | 2 | Metrics |
## Audio Routing (Android)
WarzonePhone supports three audio output routes on Android: **Earpiece**, **Speaker**, and **Bluetooth SCO**. The user cycles through available routes with a single button.
### Route lifecycle
1. Call starts → Earpiece (default). `AudioManager.MODE_IN_COMMUNICATION` set by `CallService`.
2. User taps route button → cycles to next available route.
3. Route change requires Oboe stream restart (~60-400ms) because AAudio silently tears down streams on some OEMs when the routing target changes mid-stream.
4. Bluetooth disconnect mid-call → `AudioDeviceCallback.onAudioDevicesRemoved` fires → auto-fallback to Earpiece or Speaker.
### Bluetooth SCO
SCO (Synchronous Connection Oriented) is the correct Bluetooth profile for VoIP — it provides bidirectional mono audio at 8/16 kHz with ~30ms latency. A2DP (stereo, high-quality) is unidirectional and adds 100-200ms of buffering, making it unsuitable for real-time voice.
The deprecated `AudioManager.startBluetoothSco()` API is used because the modern `setCommunicationDevice()` requires API 31+ and our minSdk is 26. The deprecated APIs are functional on all tested devices through API 35.
### Two app variants
Both the native Kotlin app (`AudioRouteManager.kt`) and the Tauri app (`android_audio.rs` JNI bridge) call the same `AudioManager` APIs. The native app uses `AudioDeviceCallback` for automatic device detection; the Tauri app queries `getDevices()` on demand.
## Network Change Response
The `AdaptiveQualityController` in `wzp-proto` reacts to network transport changes signaled via `signal_network_change(NetworkContext)`:
| Transition | Response |
|-----------|----------|
| WiFi → Cellular | Preemptive 1-tier quality downgrade + 10s FEC boost |
| Cellular → WiFi | FEC boost only (quality recovers via normal adaptive logic) |
| Any change | Reset hysteresis counters to avoid stale state |
On Android, `NetworkMonitor.kt` wraps `ConnectivityManager.NetworkCallback` and classifies the transport type using bandwidth heuristics (no `READ_PHONE_STATE` needed). The classification is delivered to the Rust engine via JNI → `AtomicU8` → recv task polling — the same lock-free cross-task signaling pattern used for adaptive profile switches.
### Cellular generation heuristics
| Downstream bandwidth | Classification |
|---------------------|---------------|
| >= 100 Mbps | 5G NR |
| >= 10 Mbps | LTE |
| < 10 Mbps | 3G or worse |
These thresholds are conservative. Carriers over-report bandwidth, but for VoIP quality decisions the exact generation matters less than the rough category.
## Build Requirements
- **Rust** 1.85+ (2024 edition)

View File

@@ -0,0 +1,98 @@
# PRD: Bluetooth Audio Routing
> Phase: Implemented
> Status: Ready for testing
> Platforms: Android (native Kotlin app + Tauri desktop app)
## Problem
WarzonePhone had `AudioRouteManager.kt` with complete Bluetooth SCO support, but it was disconnected from both UIs. Users with Bluetooth headsets had no way to route call audio to them.
## Solution
Wire Bluetooth SCO routing end-to-end through both app variants, replacing the binary speaker toggle with a 3-way audio route cycle: **Earpiece → Speaker → Bluetooth**.
## Architecture
```
┌─────────────────────────────────────────────────────┐
│ Native Kotlin App (com.wzp) │
│ │
│ InCallScreen ──► CallViewModel ──► AudioRouteManager
│ (Compose UI) cycleAudioRoute() setSpeaker() │
│ "Ear/Spk/BT" audioRoute Flow setBluetoothSco()
│ isBluetoothAvailable()
└─────────────────────────────────────────────────────┘
┌─────────────────────────────────────────────────────┐
│ Tauri Desktop App (com.wzp.desktop) │
│ │
│ main.ts ──► Tauri Commands ──► android_audio.rs │
│ cycleAudioRoute() set_bluetooth_sco() JNI calls │
│ "Ear/Spk/BT" is_bluetooth_available() │
│ get_audio_route() │
│ │
│ After each route change: Oboe stop + start │
│ (spawn_blocking to avoid stalling tokio) │
└─────────────────────────────────────────────────────┘
```
## Components Modified
### Native Kotlin App
| File | Change |
|------|--------|
| `CallViewModel.kt` | Added `audioRoute: StateFlow<AudioRoute>`, `cycleAudioRoute()`, wired `onRouteChanged` callback |
| `InCallScreen.kt` | `ControlRow` now takes `audioRoute: AudioRoute` + `onCycleRoute`, displays Ear/Spk/BT with distinct colors |
### Tauri App
| File | Change |
|------|--------|
| `android_audio.rs` | Added `start_bluetooth_sco()`, `stop_bluetooth_sco()`, `is_bluetooth_sco_on()`, `is_bluetooth_available()` |
| `lib.rs` | Added `set_bluetooth_sco`, `is_bluetooth_available`, `get_audio_route` Tauri commands |
| `main.ts` | Replaced `speakerphoneOn` toggle with `currentAudioRoute` cycling logic |
| `style.css` | Added `.bt-on` CSS class (blue-400 highlight) |
## Audio Route Lifecycle
1. **Call starts** → route defaults to Earpiece
2. **User taps route button** → cycles to next available route
3. **Route changes** → AudioManager JNI call + Oboe stream restart (~60-400ms)
4. **BT device disconnects mid-call**`AudioDeviceCallback.onAudioDevicesRemoved` fires → auto-fallback to Earpiece/Speaker
5. **Call ends** → route reset to Earpiece, BT SCO stopped
## Route Cycling Logic
```
Available routes = [Earpiece, Speaker] + [Bluetooth] if SCO device connected
Tap cycle:
Earpiece → Speaker → Bluetooth (if available) → Earpiece → ...
If BT not available:
Earpiece → Speaker → Earpiece → ...
```
## Permissions
- `BLUETOOTH_CONNECT` (Android 12+) — already in `AndroidManifest.xml`
- `MODIFY_AUDIO_SETTINGS` — already in manifest
## Known Limitations
- **SCO only** — no A2DP (stereo music profile). SCO is correct for VoIP (bidirectional mono).
- **Deprecated APIs** — `startBluetoothSco()`, `isBluetoothScoOn` are deprecated in API 31+ but still functional. Modern replacement `setCommunicationDevice()` requires API 31 and more complex device enumeration. Since minSdk is 26, deprecated path is correct.
- **No auto-switch on BT connect** — when a BT device connects mid-call, `onRouteChanged` fires but we don't auto-switch. User must tap the button.
## Testing
1. Pair a Bluetooth SCO headset with Android device
2. Start call → verify Earpiece is default
3. Tap route → Speaker (audio moves to loudspeaker, button shows "Spk")
4. Tap route → BT (audio moves to headset, button shows "BT", blue highlight)
5. Tap route → Earpiece (audio back to earpiece, button shows "Ear")
6. Disconnect BT mid-call → verify auto-fallback
7. Verify both app variants work identically
8. Verify no audio glitches during route transitions

View File

@@ -0,0 +1,129 @@
# PRD: Network Awareness
> Phase: Implemented (core path)
> Status: Ready for testing
> Platform: Android native Kotlin app (com.wzp)
## Problem
WarzonePhone's quality controller (`AdaptiveQualityController`) had a `signal_network_change()` API for proactive adaptation to WiFi↔cellular transitions, but nothing called it. Network handoffs during calls were only detected reactively via jitter spikes — by which time the user had already experienced degraded audio.
## Solution
Integrate Android's `ConnectivityManager.NetworkCallback` to detect network transport changes in real-time and feed them to the quality controller. This enables:
1. **Preemptive quality downgrade** when switching from WiFi to cellular
2. **FEC boost** (10-second window with +0.2 ratio) after any network change
3. **Faster downgrade thresholds** on cellular (2 consecutive reports vs 3 on WiFi)
## Architecture
```
┌──────────────────────────────────────────────────────────────┐
│ Android │
│ │
│ ConnectivityManager │
│ │ NetworkCallback │
│ ▼ │
│ NetworkMonitor.kt │
│ │ onNetworkChanged(type, bandwidthKbps) │
│ ▼ │
│ CallViewModel.kt ──► WzpEngine.onNetworkChanged() │
│ │ JNI │
│ ▼ │
│ jni_bridge.rs: nativeOnNetworkChanged(handle, type, bw) │
│ │ │
│ ▼ │
│ engine.rs: state.pending_network_type.store(type) │
│ │ AtomicU8 (lock-free) │
│ ▼ │
│ recv task: quality_ctrl.signal_network_change(ctx) │
│ │ │
│ ├─ Preemptive downgrade (WiFi → cellular) │
│ ├─ FEC boost 10s │
│ └─ Faster cellular thresholds │
└──────────────────────────────────────────────────────────────┘
```
## Network Classification
`NetworkMonitor` classifies the active transport without requiring `READ_PHONE_STATE` permission by using bandwidth heuristics:
| Downstream Bandwidth | Classification | Rust `NetworkContext` |
|----------------------|---------------|----------------------|
| N/A (WiFi transport) | WiFi | `WiFi` |
| >= 100 Mbps | 5G NR | `Cellular5g` |
| >= 10 Mbps | LTE | `CellularLte` |
| < 10 Mbps | 3G or worse | `Cellular3g` |
| Ethernet | WiFi (equivalent) | `WiFi` |
| Network lost | None | `Unknown` |
## Cross-Task Signaling
The network type is communicated from the JNI thread to the recv task via `AtomicU8` — the same pattern used for `pending_profile` (adaptive quality profile switches):
```
JNI thread recv task (tokio)
│ │
│ store(type, Release) │
│──────────────────────────────►│
│ │ swap(0xFF, Acquire)
│ │ if != 0xFF:
│ │ quality_ctrl.signal_network_change(ctx)
│ │
```
Sentinel value `0xFF` means "no change pending". The recv task polls on every received packet (~20-40ms), so latency is bounded by the inter-packet interval.
## Components
### New File
| File | Purpose |
|------|---------|
| `android/.../net/NetworkMonitor.kt` | ConnectivityManager callback, transport classification, deduplication |
### Modified Files
| File | Change |
|------|--------|
| `android/.../engine/WzpEngine.kt` | Added `onNetworkChanged()` method + `nativeOnNetworkChanged` external |
| `android/.../ui/call/CallViewModel.kt` | Instantiates NetworkMonitor, wires callback, register/unregister lifecycle |
| `crates/wzp-android/src/jni_bridge.rs` | Added `Java_com_wzp_engine_WzpEngine_nativeOnNetworkChanged` JNI entry |
| `crates/wzp-android/src/engine.rs` | Added `pending_network_type: AtomicU8` to EngineState, recv task polls it |
### Unchanged (already implemented)
| File | API |
|------|-----|
| `crates/wzp-proto/src/quality.rs` | `AdaptiveQualityController::signal_network_change(NetworkContext)` |
| `crates/wzp-transport/src/path_monitor.rs` | `PathMonitor::detect_handoff()` (available for future use) |
## Deferred Work
### Tauri Desktop App (com.wzp.desktop)
The Tauri engine doesn't use `AdaptiveQualityController` — quality is resolved once at call start. Adding network monitoring requires first adding adaptive quality to the Tauri call engine, which is a larger change.
### Mid-Call ICE Re-gathering
When the device's IP address changes, ideally we should:
1. Re-gather local host candidates (`local_host_candidates()`)
2. Re-probe STUN (`probe_reflect_addr()`)
3. Send updated candidates to the peer (`CandidateUpdate` signal message)
4. Attempt new dual-path race for path upgrade
`NetworkMonitor.onIpChanged` fires on `onLinkPropertiesChanged` — the hook is ready, but the signaling and re-racing logic is not yet implemented.
## Testing
1. Build native APK
2. Start a call on WiFi
3. Verify logcat: `quality controller: network context updated` with `ctx=WiFi`
4. Disable WiFi → device falls to cellular
5. Verify logcat: `ctx=CellularLte` (or `Cellular5g`/`Cellular3g`)
6. Verify FEC boost activates (check quality_ctrl logs)
7. Verify preemptive quality downgrade (tier drops one level on WiFi→cellular)
8. Re-enable WiFi → verify transition back
9. Rapid WiFi toggle (5x in 10s) → verify no crashes, deduplication works
10. Airplane mode → verify `onLost` fires with `TYPE_NONE`

View File

@@ -15,11 +15,14 @@ set -euo pipefail
# - Output: desktop/src-tauri/gen/android/.../*.apk
#
# Usage:
# ./scripts/build-tauri-android.sh # full pipeline (debug)
# ./scripts/build-tauri-android.sh # full pipeline (debug, arm64 only)
# ./scripts/build-tauri-android.sh --release # release APK
# ./scripts/build-tauri-android.sh --no-pull # skip git fetch
# ./scripts/build-tauri-android.sh --rust # force-clean rust target
# ./scripts/build-tauri-android.sh --init # also run `cargo tauri android init`
# ./scripts/build-tauri-android.sh --arch arm64 # arm64 only (default)
# ./scripts/build-tauri-android.sh --arch armv7 # armv7 only (smaller APK)
# ./scripts/build-tauri-android.sh --arch all # both arm64 + armv7 (separate APKs)
#
# Environment:
# WZP_BRANCH Branch to build (default: feat/desktop-audio-rewrite)
@@ -36,25 +39,39 @@ REBUILD_RUST=0
DO_PULL=1
DO_INIT=0
BUILD_RELEASE=0
BUILD_ARCH="arm64"
NEXT_IS_ARCH=0
for arg in "$@"; do
if [ "$NEXT_IS_ARCH" = "1" ]; then
BUILD_ARCH="$arg"
NEXT_IS_ARCH=0
continue
fi
case "$arg" in
--rust) REBUILD_RUST=1 ;;
--pull) DO_PULL=1 ;;
--no-pull) DO_PULL=0 ;;
--init) DO_INIT=1 ;;
--release) BUILD_RELEASE=1 ;;
--arch) NEXT_IS_ARCH=1 ;;
-h|--help)
sed -n '3,30p' "$0"
sed -n '3,32p' "$0"
exit 0
;;
esac
done
# Validate --arch
case "$BUILD_ARCH" in
arm64|armv7|all) ;;
*) echo "ERROR: --arch must be arm64, armv7, or all (got: $BUILD_ARCH)"; exit 1 ;;
esac
if [ -z "$BRANCH" ]; then
echo "ERROR: could not determine target branch (detached HEAD?). Pass WZP_BRANCH=name."
exit 1
fi
echo "Target branch: $BRANCH"
echo "Target branch: $BRANCH arch: $BUILD_ARCH"
log() { echo -e "\033[1;36m>>> $*\033[0m"; }
ssh_cmd() { ssh -A $SSH_OPTS "$REMOTE_HOST" "$@"; }
@@ -75,6 +92,7 @@ DO_PULL="${2:-1}"
REBUILD_RUST="${3:-0}"
DO_INIT="${4:-0}"
BUILD_RELEASE="${5:-0}"
BUILD_ARCH="${6:-arm64}"
LOG_FILE=/tmp/wzp-tauri-build.log
GIT_HASH="unknown" # populated after fetch
@@ -155,10 +173,25 @@ PROFILE_FLAG="--debug"
mkdir -p "$BASE_DIR/data/cache/android-home"
chown 1000:1000 "$BASE_DIR/data/cache/android-home" 2>/dev/null || true
# ─── Determine target architectures ──────────────────────────────────────
# Maps BUILD_ARCH to cargo-ndk ABI names and cargo-tauri target names.
# BUILD_ARCH=arm64 → one APK; BUILD_ARCH=armv7 → one APK; BUILD_ARCH=all → two APKs.
case "$BUILD_ARCH" in
arm64) ARCH_LIST="arm64" ;;
armv7) ARCH_LIST="armv7" ;;
all) ARCH_LIST="arm64 armv7" ;;
esac
# Mapping functions (used inside docker via env vars)
# cargo-ndk ABI: arm64-v8a | armeabi-v7a
# cargo-tauri: aarch64 | armv7
# NDK sysroot: aarch64-linux-android | arm-linux-androideabi
docker run --rm \
--user 1000:1000 \
-e DO_INIT="$DO_INIT" \
-e PROFILE_FLAG="$PROFILE_FLAG" \
-e BUILD_ARCH="$BUILD_ARCH" \
-v "$BASE_DIR/data/source:/build/source" \
-v "$BASE_DIR/data/cache/cargo-registry:/home/builder/.cargo/registry" \
-v "$BASE_DIR/data/cache/cargo-git:/home/builder/.cargo/git" \
@@ -185,60 +218,140 @@ if [ "${DO_INIT}" = "1" ] || [ ! -x gen/android/gradlew ]; then
cargo tauri android init 2>&1 | tail -20
fi
# ─── Arch list from BUILD_ARCH env var ───────────────────────────────────
case "${BUILD_ARCH}" in
arm64) ARCHS="arm64" ;;
armv7) ARCHS="armv7" ;;
all) ARCHS="arm64 armv7" ;;
*) ARCHS="arm64" ;;
esac
ndk_abi() {
case "$1" in
arm64) echo "arm64-v8a" ;;
armv7) echo "armeabi-v7a" ;;
esac
}
tauri_target() {
case "$1" in
arm64) echo "aarch64" ;;
armv7) echo "armv7" ;;
esac
}
ndk_sysroot_dir() {
case "$1" in
arm64) echo "aarch64-linux-android" ;;
armv7) echo "arm-linux-androideabi" ;;
esac
}
# ─── wzp-native standalone cdylib (built with cargo-ndk, not cargo-tauri) ──
# Produces libwzp_native.so which wzp-desktop dlopens at runtime via
# libloading. Split exists because cargo-tauri`s linker wiring pulls
# libloading. Split exists because cargo-tauri linker wiring pulls
# bionic private symbols into any cdylib with cc::Build C++, causing
# __init_tcb+4 SIGSEGV. cargo-ndk uses the same linker path as the
# legacy wzp-android crate which works.
echo ">>> cargo ndk build -p wzp-native --release"
JNI_ABI_DIR=gen/android/app/src/main/jniLibs/arm64-v8a
mkdir -p "$JNI_ABI_DIR"
(
cd /build/source
cargo ndk -t arm64-v8a -o desktop/src-tauri/gen/android/app/src/main/jniLibs \
build --release -p wzp-native 2>&1 | tail -10
)
if [ -f "$JNI_ABI_DIR/libwzp_native.so" ]; then
ls -lh "$JNI_ABI_DIR/libwzp_native.so"
else
echo ">>> WARNING: libwzp_native.so not produced"
fi
JNILIBS_BASE=gen/android/app/src/main/jniLibs
# ─── libc++_shared.so — required by wzp-native at runtime ──────────────
# wzp-native/build.rs uses cpp_link_stdlib(Some("c++_shared")) which adds
# a NEEDED entry for libc++_shared.so to libwzp_native.so. cargo-ndk does
# NOT copy the actual libc++_shared.so into jniLibs, so unless we copy it
# explicitly, the APK ships without it and the Android dynamic linker
# fails the dlopen with "library libc++_shared.so not found" at runtime.
# Same fix that build-and-notify.sh has had for the legacy wzp-android
# path (lines 126-134 there) — ported here for the Tauri pipeline.
# NOTE: no apostrophes in this comment block. The enclosing docker
# bash -c uses single quotes and a stray apostrophe closes the string
# prematurely, breaking variable scope for everything below.
if [ ! -f "$JNI_ABI_DIR/libc++_shared.so" ]; then
echo ">>> libc++_shared.so missing, copying from NDK..."
NDK_LIBCXX=$(find "$ANDROID_NDK_HOME" -name "libc++_shared.so" -path "*/aarch64-linux-android/*" | head -1)
if [ -n "$NDK_LIBCXX" ]; then
cp "$NDK_LIBCXX" "$JNI_ABI_DIR/"
ls -lh "$JNI_ABI_DIR/libc++_shared.so"
for ARCH in $ARCHS; do
ABI=$(ndk_abi "$ARCH")
SYSROOT_DIR=$(ndk_sysroot_dir "$ARCH")
JNI_ABI_DIR="$JNILIBS_BASE/$ABI"
mkdir -p "$JNI_ABI_DIR"
echo ">>> cargo ndk build -p wzp-native --release -t $ABI"
(
cd /build/source
cargo ndk -t "$ABI" -o "desktop/src-tauri/$JNILIBS_BASE" \
build --release -p wzp-native 2>&1 | tail -10
)
if [ -f "$JNI_ABI_DIR/libwzp_native.so" ]; then
ls -lh "$JNI_ABI_DIR/libwzp_native.so"
else
echo ">>> ERROR: libc++_shared.so not found in NDK — APK will crash at dlopen time"
exit 1
echo ">>> WARNING: libwzp_native.so not produced for $ABI"
fi
fi
echo ">>> cargo tauri android build ${PROFILE_FLAG} --target aarch64 --apk"
cargo tauri android build ${PROFILE_FLAG} --target aarch64 --apk
# ─── libc++_shared.so — required by wzp-native at runtime ────────────
# wzp-native/build.rs uses cpp_link_stdlib(Some("c++_shared")) which adds
# a NEEDED entry for libc++_shared.so to libwzp_native.so. cargo-ndk does
# NOT copy the actual libc++_shared.so into jniLibs, so unless we copy it
# explicitly, the APK ships without it and the Android dynamic linker
# fails the dlopen with "library libc++_shared.so not found" at runtime.
if [ ! -f "$JNI_ABI_DIR/libc++_shared.so" ]; then
echo ">>> libc++_shared.so missing for $ABI, copying from NDK..."
NDK_LIBCXX=$(find "$ANDROID_NDK_HOME" -name "libc++_shared.so" -path "*/${SYSROOT_DIR}/*" | head -1)
if [ -n "$NDK_LIBCXX" ]; then
cp "$NDK_LIBCXX" "$JNI_ABI_DIR/"
ls -lh "$JNI_ABI_DIR/libc++_shared.so"
else
echo ">>> ERROR: libc++_shared.so not found in NDK for $ABI — APK will crash at dlopen time"
exit 1
fi
fi
done
# ─── Build per-arch APKs ────────────────────────────────────────────────
# When building for a single arch, only that arch jniLibs dir exists so
# the APK is naturally single-arch and smaller.
# When building --arch all, we produce SEPARATE per-arch APKs by:
# 1. Building each target individually with cargo tauri android build
# 2. Temporarily hiding the other arch jniLibs so the APK only contains one
# This keeps APKs small (~15-20MB instead of ~30-40MB for universal).
APK_OUTPUT_DIR="/build/source/target/apk-output"
mkdir -p "$APK_OUTPUT_DIR"
for ARCH in $ARCHS; do
TARGET=$(tauri_target "$ARCH")
ABI=$(ndk_abi "$ARCH")
# If building all, temporarily hide other arches to get single-arch APK
if [ "${BUILD_ARCH}" = "all" ]; then
for OTHER_ARCH in $ARCHS; do
OTHER_ABI=$(ndk_abi "$OTHER_ARCH")
if [ "$OTHER_ABI" != "$ABI" ] && [ -d "$JNILIBS_BASE/$OTHER_ABI" ]; then
mv "$JNILIBS_BASE/$OTHER_ABI" "$JNILIBS_BASE/_hide_$OTHER_ABI"
fi
done
fi
echo ""
echo ">>> cargo tauri android build ${PROFILE_FLAG} --target $TARGET --apk"
cargo tauri android build ${PROFILE_FLAG} --target "$TARGET" --apk
# Copy produced APK with arch suffix
BUILT_APK=$(find gen/android -name "*.apk" -newer "$APK_OUTPUT_DIR" -type f 2>/dev/null | head -1)
if [ -z "$BUILT_APK" ]; then
BUILT_APK=$(find gen/android -name "*.apk" -type f 2>/dev/null | sort -t/ -k1 | tail -1)
fi
if [ -n "$BUILT_APK" ]; then
cp "$BUILT_APK" "$APK_OUTPUT_DIR/wzp-tauri-${ARCH}.apk"
echo ">>> $ARCH APK: $(ls -lh "$APK_OUTPUT_DIR/wzp-tauri-${ARCH}.apk" | awk "{print \$5}")"
fi
# Restore hidden arches
if [ "${BUILD_ARCH}" = "all" ]; then
for OTHER_ARCH in $ARCHS; do
OTHER_ABI=$(ndk_abi "$OTHER_ARCH")
if [ "$OTHER_ABI" != "$ABI" ] && [ -d "$JNILIBS_BASE/_hide_$OTHER_ABI" ]; then
mv "$JNILIBS_BASE/_hide_$OTHER_ABI" "$JNILIBS_BASE/$OTHER_ABI"
fi
done
fi
done
echo ""
echo ">>> Build artifacts:"
find gen/android -name "*.apk" -exec ls -lh {} \; 2>/dev/null
ls -lh "$APK_OUTPUT_DIR/"*.apk 2>/dev/null || echo " (none)"
'
# Locate the produced APK
APK=$(find "$BASE_DIR/data/source/desktop/src-tauri/gen/android" -name "*.apk" -type f 2>/dev/null | head -1)
if [ -z "$APK" ] || [ ! -f "$APK" ]; then
# ─── Collect and upload APKs ────────────────────────────────────────────
APK_OUTPUT="$BASE_DIR/data/source/target/apk-output"
APK_LIST=$(find "$APK_OUTPUT" -name "wzp-tauri-*.apk" -type f 2>/dev/null | sort)
if [ -z "$APK_LIST" ]; then
LOG_URL=$(upload_to_rustypaste "$LOG_FILE" || echo "")
if [ -n "$LOG_URL" ]; then
notify "WZP Tauri Android build [$GIT_HASH]: no APK produced
@@ -248,35 +361,56 @@ log: $LOG_URL"
fi
exit 1
fi
APK_SIZE=$(du -h "$APK" | cut -f1)
RUSTY_URL=$(upload_to_rustypaste "$APK" || echo "")
if [ -n "$RUSTY_URL" ]; then
notify "WZP Tauri Android build OK [$GIT_HASH] ($APK_SIZE)
$RUSTY_URL"
else
notify "WZP Tauri Android build OK [$GIT_HASH] ($APK_SIZE) — rustypaste upload skipped"
fi
# Upload each APK and collect URLs
NOTIFY_MSG="WZP Tauri Android build OK [$GIT_HASH] ($BUILD_ARCH)"
APK_PATHS=""
for APK in $APK_LIST; do
APK_NAME=$(basename "$APK")
APK_SIZE=$(du -h "$APK" | cut -f1)
RUSTY_URL=$(upload_to_rustypaste "$APK" || echo "")
if [ -n "$RUSTY_URL" ]; then
NOTIFY_MSG="$NOTIFY_MSG
$APK_NAME ($APK_SIZE): $RUSTY_URL"
else
NOTIFY_MSG="$NOTIFY_MSG
$APK_NAME ($APK_SIZE) — upload skipped"
fi
APK_PATHS="$APK_PATHS $APK"
done
notify "$NOTIFY_MSG"
# Print path so the local script can grab it
echo "APK_REMOTE_PATH=$APK"
# Print paths so the local script can grab them
for APK in $APK_LIST; do
echo "APK_REMOTE_PATH=$APK"
done
REMOTE_SCRIPT
ssh_cmd "chmod +x /tmp/wzp-tauri-build.sh"
notify_local "WZP Tauri Android build dispatched (branch=$BRANCH, release=$BUILD_RELEASE)"
log "Triggering remote build (branch=$BRANCH)..."
notify_local "WZP Tauri Android build dispatched (branch=$BRANCH, arch=$BUILD_ARCH, release=$BUILD_RELEASE)"
log "Triggering remote build (branch=$BRANCH, arch=$BUILD_ARCH)..."
# Run; capture full output, last line is APK_REMOTE_PATH=...
REMOTE_OUTPUT=$(ssh_cmd "/tmp/wzp-tauri-build.sh '$BRANCH' '$DO_PULL' '$REBUILD_RUST' '$DO_INIT' '$BUILD_RELEASE'" || true)
# Run; last lines are APK_REMOTE_PATH=... (one per arch)
REMOTE_OUTPUT=$(ssh_cmd "/tmp/wzp-tauri-build.sh '$BRANCH' '$DO_PULL' '$REBUILD_RUST' '$DO_INIT' '$BUILD_RELEASE' '$BUILD_ARCH'" || true)
echo "$REMOTE_OUTPUT" | tail -60
APK_REMOTE=$(echo "$REMOTE_OUTPUT" | grep '^APK_REMOTE_PATH=' | tail -1 | cut -d= -f2-)
if [ -n "$APK_REMOTE" ]; then
log "Downloading APK to $LOCAL_OUTPUT/wzp-tauri.apk..."
scp $SSH_OPTS "$REMOTE_HOST:$APK_REMOTE" "$LOCAL_OUTPUT/wzp-tauri.apk"
echo " $LOCAL_OUTPUT/wzp-tauri.apk ($(du -h "$LOCAL_OUTPUT/wzp-tauri.apk" | cut -f1))"
else
# Download all produced APKs
APK_REMOTES=$(echo "$REMOTE_OUTPUT" | grep '^APK_REMOTE_PATH=' | cut -d= -f2-)
if [ -z "$APK_REMOTES" ]; then
log "No APK produced — see ntfy / remote log /tmp/wzp-tauri-build.log"
exit 1
fi
DOWNLOADED=0
echo "$APK_REMOTES" | while IFS= read -r APK_REMOTE; do
[ -z "$APK_REMOTE" ] && continue
APK_NAME=$(basename "$APK_REMOTE")
log "Downloading $APK_NAME..."
scp $SSH_OPTS "$REMOTE_HOST:$APK_REMOTE" "$LOCAL_OUTPUT/$APK_NAME"
echo " $LOCAL_OUTPUT/$APK_NAME ($(du -h "$LOCAL_OUTPUT/$APK_NAME" | cut -f1))"
DOWNLOADED=$((DOWNLOADED + 1))
done
log "Done! APKs in $LOCAL_OUTPUT/"
ls -lh "$LOCAL_OUTPUT"/wzp-tauri-*.apk 2>/dev/null || true