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) 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. */ /** Destroy the native engine and free all resources. The instance must not be reused. */
@Synchronized @Synchronized
fun destroy() { 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 nativeStartSignaling(handle: Long, relay: String, seed: String, token: String, alias: String): Int
private external fun nativePlaceCall(handle: Long, targetFp: 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 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. * 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.ViewModel
import androidx.lifecycle.viewModelScope import androidx.lifecycle.viewModelScope
import com.wzp.audio.AudioPipeline import com.wzp.audio.AudioPipeline
import com.wzp.audio.AudioRoute
import com.wzp.audio.AudioRouteManager import com.wzp.audio.AudioRouteManager
import com.wzp.data.SettingsRepository import com.wzp.data.SettingsRepository
import com.wzp.debug.DebugReporter import com.wzp.debug.DebugReporter
@@ -12,6 +13,7 @@ import com.wzp.engine.CallStats
import com.wzp.service.CallService import com.wzp.service.CallService
import com.wzp.engine.WzpCallback import com.wzp.engine.WzpCallback
import com.wzp.engine.WzpEngine import com.wzp.engine.WzpEngine
import com.wzp.net.NetworkMonitor
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job import kotlinx.coroutines.Job
import kotlinx.coroutines.delay import kotlinx.coroutines.delay
@@ -43,6 +45,7 @@ class CallViewModel : ViewModel(), WzpCallback {
private var engineInitialized = false private var engineInitialized = false
private var audioPipeline: AudioPipeline? = null private var audioPipeline: AudioPipeline? = null
private var audioRouteManager: AudioRouteManager? = null private var audioRouteManager: AudioRouteManager? = null
private var networkMonitor: NetworkMonitor? = null
private var audioStarted = false private var audioStarted = false
private var appContext: Context? = null private var appContext: Context? = null
private var settings: SettingsRepository? = null private var settings: SettingsRepository? = null
@@ -60,6 +63,9 @@ class CallViewModel : ViewModel(), WzpCallback {
private val _isSpeaker = MutableStateFlow(false) private val _isSpeaker = MutableStateFlow(false)
val isSpeaker: StateFlow<Boolean> = _isSpeaker.asStateFlow() val isSpeaker: StateFlow<Boolean> = _isSpeaker.asStateFlow()
private val _audioRoute = MutableStateFlow(AudioRoute.EARPIECE)
val audioRoute: StateFlow<AudioRoute> = _audioRoute.asStateFlow()
private val _stats = MutableStateFlow(CallStats()) private val _stats = MutableStateFlow(CallStats())
val stats: StateFlow<CallStats> = _stats.asStateFlow() val stats: StateFlow<CallStats> = _stats.asStateFlow()
@@ -226,7 +232,19 @@ class CallViewModel : ViewModel(), WzpCallback {
audioPipeline = AudioPipeline(appCtx) audioPipeline = AudioPipeline(appCtx)
} }
if (audioRouteManager == null) { 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) { if (debugReporter == null) {
debugReporter = DebugReporter(appCtx) debugReporter = DebugReporter(appCtx)
@@ -607,6 +625,27 @@ class CallViewModel : ViewModel(), WzpCallback {
audioRouteManager?.setSpeaker(newSpeaker) 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 clearError() { _errorMessage.value = null }
fun sendDebugReport() { fun sendDebugReport() {
@@ -661,6 +700,7 @@ class CallViewModel : ViewModel(), WzpCallback {
it.start(e) it.start(e)
} }
audioRouteManager?.register() audioRouteManager?.register()
networkMonitor?.register()
audioStarted = true audioStarted = true
} }
@@ -668,8 +708,10 @@ class CallViewModel : ViewModel(), WzpCallback {
if (!audioStarted) return if (!audioStarted) return
audioPipeline?.stop() // sets running=false; DON'T null — teardown needs awaitDrain() audioPipeline?.stop() // sets running=false; DON'T null — teardown needs awaitDrain()
audioRouteManager?.unregister() audioRouteManager?.unregister()
networkMonitor?.unregister()
audioRouteManager?.setSpeaker(false) audioRouteManager?.setSpeaker(false)
_isSpeaker.value = false _isSpeaker.value = false
_audioRoute.value = AudioRoute.EARPIECE
audioStarted = false 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.text.style.TextAlign
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp import androidx.compose.ui.unit.sp
import com.wzp.audio.AudioRoute
import com.wzp.engine.CallStats import com.wzp.engine.CallStats
import com.wzp.ui.components.CopyableFingerprint import com.wzp.ui.components.CopyableFingerprint
import com.wzp.ui.components.Identicon import com.wzp.ui.components.Identicon
@@ -74,6 +75,7 @@ fun InCallScreen(
val callState by viewModel.callState.collectAsState() val callState by viewModel.callState.collectAsState()
val isMuted by viewModel.isMuted.collectAsState() val isMuted by viewModel.isMuted.collectAsState()
val isSpeaker by viewModel.isSpeaker.collectAsState() val isSpeaker by viewModel.isSpeaker.collectAsState()
val audioRoute by viewModel.audioRoute.collectAsState()
val stats by viewModel.stats.collectAsState() val stats by viewModel.stats.collectAsState()
val qualityTier by viewModel.qualityTier.collectAsState() val qualityTier by viewModel.qualityTier.collectAsState()
val errorMessage by viewModel.errorMessage.collectAsState() val errorMessage by viewModel.errorMessage.collectAsState()
@@ -621,12 +623,12 @@ fun InCallScreen(
Spacer(modifier = Modifier.height(16.dp)) Spacer(modifier = Modifier.height(16.dp))
// Controls: Mic / End / Spk // Controls: Mic / End / Route (Ear/Spk/BT)
ControlRow( ControlRow(
isMuted = isMuted, isMuted = isMuted,
isSpeaker = isSpeaker, audioRoute = audioRoute,
onToggleMute = viewModel::toggleMute, onToggleMute = viewModel::toggleMute,
onToggleSpeaker = viewModel::toggleSpeaker, onCycleRoute = viewModel::cycleAudioRoute,
onHangUp = { viewModel.stopCall() } onHangUp = { viewModel.stopCall() }
) )
@@ -915,9 +917,9 @@ private fun AudioLevelBar(audioLevel: Int) {
@Composable @Composable
private fun ControlRow( private fun ControlRow(
isMuted: Boolean, isMuted: Boolean,
isSpeaker: Boolean, audioRoute: AudioRoute,
onToggleMute: () -> Unit, onToggleMute: () -> Unit,
onToggleSpeaker: () -> Unit, onCycleRoute: () -> Unit,
onHangUp: () -> Unit onHangUp: () -> Unit
) { ) {
Row( Row(
@@ -959,22 +961,28 @@ private fun ControlRow(
Text("End", style = MaterialTheme.typography.titleMedium.copy(fontWeight = FontWeight.Bold)) Text("End", style = MaterialTheme.typography.titleMedium.copy(fontWeight = FontWeight.Bold))
} }
// Speaker // Audio route: cycles Earpiece → Speaker → Bluetooth (when available)
FilledTonalIconButton( FilledTonalIconButton(
onClick = onToggleSpeaker, onClick = onCycleRoute,
modifier = Modifier.size(56.dp), modifier = Modifier.size(56.dp),
colors = if (isSpeaker) { colors = when (audioRoute) {
IconButtonDefaults.filledTonalIconButtonColors( AudioRoute.SPEAKER -> IconButtonDefaults.filledTonalIconButtonColors(
containerColor = Color(0xFF0F3460), contentColor = Color.White containerColor = Color(0xFF0F3460), contentColor = Color.White
) )
} else { AudioRoute.BLUETOOTH -> IconButtonDefaults.filledTonalIconButtonColors(
IconButtonDefaults.filledTonalIconButtonColors( containerColor = Color(0xFF2563EB), contentColor = Color.White
)
else -> IconButtonDefaults.filledTonalIconButtonColors(
containerColor = DarkSurface2, contentColor = Color.White containerColor = DarkSurface2, contentColor = Color.White
) )
} }
) { ) {
Text( Text(
text = if (isSpeaker) "Spk\nOn" else "Spk", text = when (audioRoute) {
AudioRoute.EARPIECE -> "Ear"
AudioRoute.SPEAKER -> "Spk"
AudioRoute.BLUETOOTH -> "BT"
},
textAlign = TextAlign.Center, textAlign = TextAlign.Center,
style = MaterialTheme.typography.labelSmall, style = MaterialTheme.typography.labelSmall,
lineHeight = 12.sp lineHeight = 12.sp

View File

@@ -99,6 +99,9 @@ pub(crate) struct EngineState {
/// QUIC transport handle — stored so stop_call() can close it immediately, /// QUIC transport handle — stored so stop_call() can close it immediately,
/// triggering relay-side leave + RoomUpdate broadcast. /// triggering relay-side leave + RoomUpdate broadcast.
pub quic_transport: Mutex<Option<Arc<wzp_transport::QuinnTransport>>>, 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 { pub struct WzpEngine {
@@ -120,6 +123,7 @@ impl WzpEngine {
playout_ring: AudioRing::new(), playout_ring: AudioRing::new(),
audio_level_rms: AtomicU32::new(0), audio_level_rms: AtomicU32::new(0),
quic_transport: Mutex::new(None), quic_transport: Mutex::new(None),
pending_network_type: AtomicU8::new(PROFILE_NO_CHANGE),
}); });
Self { Self {
state, state,
@@ -404,6 +408,13 @@ impl WzpEngine {
pub fn force_profile(&self, _profile: QualityProfile) {} 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 { pub fn get_stats(&self) -> CallStats {
let mut stats = self.state.stats.lock().unwrap().clone(); let mut stats = self.state.stats.lock().unwrap().clone();
if let Some(start) = self.call_start { 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 // Adaptive quality: ingest quality reports from relay
if auto_profile { if auto_profile {
if let Some(ref qr) = pkt.quality_report { 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. /// Write captured PCM samples from Kotlin AudioRecord into the engine's capture ring.
/// pcm is a Java short[] array. /// pcm is a Java short[] array.
#[unsafe(no_mangle)] #[unsafe(no_mangle)]

View File

@@ -96,3 +96,134 @@ pub fn is_speakerphone_on() -> Result<bool, String> {
.map_err(|e| format!("isSpeakerphoneOn: {e}"))?; .map_err(|e| format!("isSpeakerphoneOn: {e}"))?;
Ok(on) 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 ─────────────────────────────────────────────────── // ─── Call history commands ───────────────────────────────────────────────────
#[tauri::command] #[tauri::command]
@@ -1892,6 +1957,7 @@ pub fn run() {
hangup_call, hangup_call,
deregister, deregister,
set_speakerphone, is_speakerphone_on, set_speakerphone, is_speakerphone_on,
set_bluetooth_sco, is_bluetooth_available, get_audio_route,
get_call_history, get_recent_contacts, clear_call_history, get_call_history, get_recent_contacts, clear_call_history,
set_dred_verbose_logs, get_dred_verbose_logs, set_dred_verbose_logs, get_dred_verbose_logs,
set_call_debug_logs, get_call_debug_logs, set_call_debug_logs, get_call_debug_logs,

View File

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

View File

@@ -1083,7 +1083,10 @@ button.primary:disabled { opacity: 0.5; cursor: not-allowed; }
color: white; 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 { #spk-btn.speaker-on .icon {
color: var(--accent); 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. 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). 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-client | 30 + 2 integration | Encoder/decoder, quality adapter, silence, drift, sweep |
| wzp-web | 2 | Metrics | | 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 ## Build Requirements
- **Rust** 1.85+ (2024 edition) - **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 # - Output: desktop/src-tauri/gen/android/.../*.apk
# #
# Usage: # 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 --release # release APK
# ./scripts/build-tauri-android.sh --no-pull # skip git fetch # ./scripts/build-tauri-android.sh --no-pull # skip git fetch
# ./scripts/build-tauri-android.sh --rust # force-clean rust target # ./scripts/build-tauri-android.sh --rust # force-clean rust target
# ./scripts/build-tauri-android.sh --init # also run `cargo tauri android init` # ./scripts/build-tauri-android.sh --init # also run `cargo tauri android init`
# ./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: # Environment:
# WZP_BRANCH Branch to build (default: feat/desktop-audio-rewrite) # WZP_BRANCH Branch to build (default: feat/desktop-audio-rewrite)
@@ -36,25 +39,39 @@ REBUILD_RUST=0
DO_PULL=1 DO_PULL=1
DO_INIT=0 DO_INIT=0
BUILD_RELEASE=0 BUILD_RELEASE=0
BUILD_ARCH="arm64"
NEXT_IS_ARCH=0
for arg in "$@"; do for arg in "$@"; do
if [ "$NEXT_IS_ARCH" = "1" ]; then
BUILD_ARCH="$arg"
NEXT_IS_ARCH=0
continue
fi
case "$arg" in case "$arg" in
--rust) REBUILD_RUST=1 ;; --rust) REBUILD_RUST=1 ;;
--pull) DO_PULL=1 ;; --pull) DO_PULL=1 ;;
--no-pull) DO_PULL=0 ;; --no-pull) DO_PULL=0 ;;
--init) DO_INIT=1 ;; --init) DO_INIT=1 ;;
--release) BUILD_RELEASE=1 ;; --release) BUILD_RELEASE=1 ;;
--arch) NEXT_IS_ARCH=1 ;;
-h|--help) -h|--help)
sed -n '3,30p' "$0" sed -n '3,32p' "$0"
exit 0 exit 0
;; ;;
esac esac
done 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 if [ -z "$BRANCH" ]; then
echo "ERROR: could not determine target branch (detached HEAD?). Pass WZP_BRANCH=name." echo "ERROR: could not determine target branch (detached HEAD?). Pass WZP_BRANCH=name."
exit 1 exit 1
fi fi
echo "Target branch: $BRANCH" echo "Target branch: $BRANCH arch: $BUILD_ARCH"
log() { echo -e "\033[1;36m>>> $*\033[0m"; } log() { echo -e "\033[1;36m>>> $*\033[0m"; }
ssh_cmd() { ssh -A $SSH_OPTS "$REMOTE_HOST" "$@"; } ssh_cmd() { ssh -A $SSH_OPTS "$REMOTE_HOST" "$@"; }
@@ -75,6 +92,7 @@ DO_PULL="${2:-1}"
REBUILD_RUST="${3:-0}" REBUILD_RUST="${3:-0}"
DO_INIT="${4:-0}" DO_INIT="${4:-0}"
BUILD_RELEASE="${5:-0}" BUILD_RELEASE="${5:-0}"
BUILD_ARCH="${6:-arm64}"
LOG_FILE=/tmp/wzp-tauri-build.log LOG_FILE=/tmp/wzp-tauri-build.log
GIT_HASH="unknown" # populated after fetch GIT_HASH="unknown" # populated after fetch
@@ -155,10 +173,25 @@ PROFILE_FLAG="--debug"
mkdir -p "$BASE_DIR/data/cache/android-home" mkdir -p "$BASE_DIR/data/cache/android-home"
chown 1000:1000 "$BASE_DIR/data/cache/android-home" 2>/dev/null || true 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 \ docker run --rm \
--user 1000:1000 \ --user 1000:1000 \
-e DO_INIT="$DO_INIT" \ -e DO_INIT="$DO_INIT" \
-e PROFILE_FLAG="$PROFILE_FLAG" \ -e PROFILE_FLAG="$PROFILE_FLAG" \
-e BUILD_ARCH="$BUILD_ARCH" \
-v "$BASE_DIR/data/source:/build/source" \ -v "$BASE_DIR/data/source:/build/source" \
-v "$BASE_DIR/data/cache/cargo-registry:/home/builder/.cargo/registry" \ -v "$BASE_DIR/data/cache/cargo-registry:/home/builder/.cargo/registry" \
-v "$BASE_DIR/data/cache/cargo-git:/home/builder/.cargo/git" \ -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 cargo tauri android init 2>&1 | tail -20
fi 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) ── # ─── wzp-native standalone cdylib (built with cargo-ndk, not cargo-tauri) ──
# Produces libwzp_native.so which wzp-desktop dlopens at runtime via # 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 # bionic private symbols into any cdylib with cc::Build C++, causing
# __init_tcb+4 SIGSEGV. cargo-ndk uses the same linker path as the # __init_tcb+4 SIGSEGV. cargo-ndk uses the same linker path as the
# legacy wzp-android crate which works. # legacy wzp-android crate which works.
echo ">>> cargo ndk build -p wzp-native --release" JNILIBS_BASE=gen/android/app/src/main/jniLibs
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
# ─── libc++_shared.so — required by wzp-native at runtime ────────────── for ARCH in $ARCHS; do
# wzp-native/build.rs uses cpp_link_stdlib(Some("c++_shared")) which adds ABI=$(ndk_abi "$ARCH")
# a NEEDED entry for libc++_shared.so to libwzp_native.so. cargo-ndk does SYSROOT_DIR=$(ndk_sysroot_dir "$ARCH")
# NOT copy the actual libc++_shared.so into jniLibs, so unless we copy it JNI_ABI_DIR="$JNILIBS_BASE/$ABI"
# explicitly, the APK ships without it and the Android dynamic linker mkdir -p "$JNI_ABI_DIR"
# 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 echo ">>> cargo ndk build -p wzp-native --release -t $ABI"
# path (lines 126-134 there) — ported here for the Tauri pipeline. (
# NOTE: no apostrophes in this comment block. The enclosing docker cd /build/source
# bash -c uses single quotes and a stray apostrophe closes the string cargo ndk -t "$ABI" -o "desktop/src-tauri/$JNILIBS_BASE" \
# prematurely, breaking variable scope for everything below. build --release -p wzp-native 2>&1 | tail -10
if [ ! -f "$JNI_ABI_DIR/libc++_shared.so" ]; then )
echo ">>> libc++_shared.so missing, copying from NDK..." if [ -f "$JNI_ABI_DIR/libwzp_native.so" ]; then
NDK_LIBCXX=$(find "$ANDROID_NDK_HOME" -name "libc++_shared.so" -path "*/aarch64-linux-android/*" | head -1) ls -lh "$JNI_ABI_DIR/libwzp_native.so"
else
echo ">>> WARNING: libwzp_native.so not produced for $ABI"
fi
# ─── 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 if [ -n "$NDK_LIBCXX" ]; then
cp "$NDK_LIBCXX" "$JNI_ABI_DIR/" cp "$NDK_LIBCXX" "$JNI_ABI_DIR/"
ls -lh "$JNI_ABI_DIR/libc++_shared.so" ls -lh "$JNI_ABI_DIR/libc++_shared.so"
else else
echo ">>> ERROR: libc++_shared.so not found in NDK — APK will crash at dlopen time" echo ">>> ERROR: libc++_shared.so not found in NDK for $ABI — APK will crash at dlopen time"
exit 1 exit 1
fi fi
fi fi
done
echo ">>> cargo tauri android build ${PROFILE_FLAG} --target aarch64 --apk" # ─── Build per-arch APKs ────────────────────────────────────────────────
cargo tauri android build ${PROFILE_FLAG} --target aarch64 --apk # 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 ""
echo ">>> Build artifacts:" 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 # ─── Collect and upload APKs ────────────────────────────────────────────
APK=$(find "$BASE_DIR/data/source/desktop/src-tauri/gen/android" -name "*.apk" -type f 2>/dev/null | head -1) APK_OUTPUT="$BASE_DIR/data/source/target/apk-output"
if [ -z "$APK" ] || [ ! -f "$APK" ]; then 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 "") LOG_URL=$(upload_to_rustypaste "$LOG_FILE" || echo "")
if [ -n "$LOG_URL" ]; then if [ -n "$LOG_URL" ]; then
notify "WZP Tauri Android build [$GIT_HASH]: no APK produced notify "WZP Tauri Android build [$GIT_HASH]: no APK produced
@@ -248,35 +361,56 @@ log: $LOG_URL"
fi fi
exit 1 exit 1
fi fi
APK_SIZE=$(du -h "$APK" | cut -f1)
RUSTY_URL=$(upload_to_rustypaste "$APK" || echo "") # Upload each APK and collect URLs
if [ -n "$RUSTY_URL" ]; then NOTIFY_MSG="WZP Tauri Android build OK [$GIT_HASH] ($BUILD_ARCH)"
notify "WZP Tauri Android build OK [$GIT_HASH] ($APK_SIZE) APK_PATHS=""
$RUSTY_URL" for APK in $APK_LIST; do
else APK_NAME=$(basename "$APK")
notify "WZP Tauri Android build OK [$GIT_HASH] ($APK_SIZE) — rustypaste upload skipped" APK_SIZE=$(du -h "$APK" | cut -f1)
fi 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 # Print paths so the local script can grab them
echo "APK_REMOTE_PATH=$APK" for APK in $APK_LIST; do
echo "APK_REMOTE_PATH=$APK"
done
REMOTE_SCRIPT REMOTE_SCRIPT
ssh_cmd "chmod +x /tmp/wzp-tauri-build.sh" ssh_cmd "chmod +x /tmp/wzp-tauri-build.sh"
notify_local "WZP Tauri Android build dispatched (branch=$BRANCH, release=$BUILD_RELEASE)" notify_local "WZP Tauri Android build dispatched (branch=$BRANCH, arch=$BUILD_ARCH, release=$BUILD_RELEASE)"
log "Triggering remote build (branch=$BRANCH)..." log "Triggering remote build (branch=$BRANCH, arch=$BUILD_ARCH)..."
# Run; capture full output, last line is APK_REMOTE_PATH=... # 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'" || true) 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 echo "$REMOTE_OUTPUT" | tail -60
APK_REMOTE=$(echo "$REMOTE_OUTPUT" | grep '^APK_REMOTE_PATH=' | tail -1 | cut -d= -f2-) # Download all produced APKs
if [ -n "$APK_REMOTE" ]; then APK_REMOTES=$(echo "$REMOTE_OUTPUT" | grep '^APK_REMOTE_PATH=' | cut -d= -f2-)
log "Downloading APK to $LOCAL_OUTPUT/wzp-tauri.apk..." if [ -z "$APK_REMOTES" ]; then
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
log "No APK produced — see ntfy / remote log /tmp/wzp-tauri-build.log" log "No APK produced — see ntfy / remote log /tmp/wzp-tauri-build.log"
exit 1 exit 1
fi 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