From 4c1ad841e1b382b055a1d74a71c195bd4a72b9ab Mon Sep 17 00:00:00 2001 From: Siavash Sameni Date: Sun, 12 Apr 2026 16:07:41 +0400 Subject: [PATCH] feat(android): Bluetooth audio routing + network change detection + per-arch APK builds MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- .../src/main/java/com/wzp/engine/WzpEngine.kt | 12 + .../main/java/com/wzp/net/NetworkMonitor.kt | 141 ++++++++++ .../java/com/wzp/ui/call/CallViewModel.kt | 44 ++- .../main/java/com/wzp/ui/call/InCallScreen.kt | 32 ++- crates/wzp-android/src/engine.rs | 28 ++ crates/wzp-android/src/jni_bridge.rs | 23 ++ desktop/src-tauri/src/android_audio.rs | 131 +++++++++ desktop/src-tauri/src/lib.rs | 66 +++++ desktop/src/main.ts | 91 ++++-- desktop/src/style.css | 5 +- docs/ARCHITECTURE.md | 52 ++++ docs/DESIGN.md | 43 +++ docs/PRD-bluetooth-audio.md | 98 +++++++ docs/PRD-network-awareness.md | 129 +++++++++ scripts/build-tauri-android.sh | 260 +++++++++++++----- 15 files changed, 1050 insertions(+), 105 deletions(-) create mode 100644 android/app/src/main/java/com/wzp/net/NetworkMonitor.kt create mode 100644 docs/PRD-bluetooth-audio.md create mode 100644 docs/PRD-network-awareness.md diff --git a/android/app/src/main/java/com/wzp/engine/WzpEngine.kt b/android/app/src/main/java/com/wzp/engine/WzpEngine.kt index bfd05cf..c5307cf 100644 --- a/android/app/src/main/java/com/wzp/engine/WzpEngine.kt +++ b/android/app/src/main/java/com/wzp/engine/WzpEngine.kt @@ -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. diff --git a/android/app/src/main/java/com/wzp/net/NetworkMonitor.kt b/android/app/src/main/java/com/wzp/net/NetworkMonitor.kt new file mode 100644 index 0000000..d5a246d --- /dev/null +++ b/android/app/src/main/java/com/wzp/net/NetworkMonitor.kt @@ -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 + } +} diff --git a/android/app/src/main/java/com/wzp/ui/call/CallViewModel.kt b/android/app/src/main/java/com/wzp/ui/call/CallViewModel.kt index eb183f3..7022e74 100644 --- a/android/app/src/main/java/com/wzp/ui/call/CallViewModel.kt +++ b/android/app/src/main/java/com/wzp/ui/call/CallViewModel.kt @@ -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 = _isSpeaker.asStateFlow() + private val _audioRoute = MutableStateFlow(AudioRoute.EARPIECE) + val audioRoute: StateFlow = _audioRoute.asStateFlow() + private val _stats = MutableStateFlow(CallStats()) val stats: StateFlow = _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 } diff --git a/android/app/src/main/java/com/wzp/ui/call/InCallScreen.kt b/android/app/src/main/java/com/wzp/ui/call/InCallScreen.kt index e8f0de2..6dd5934 100644 --- a/android/app/src/main/java/com/wzp/ui/call/InCallScreen.kt +++ b/android/app/src/main/java/com/wzp/ui/call/InCallScreen.kt @@ -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 diff --git a/crates/wzp-android/src/engine.rs b/crates/wzp-android/src/engine.rs index c3e6520..fea1f6e 100644 --- a/crates/wzp-android/src/engine.rs +++ b/crates/wzp-android/src/engine.rs @@ -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>>, + /// 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 { diff --git a/crates/wzp-android/src/jni_bridge.rs b/crates/wzp-android/src/jni_bridge.rs index b452c34..bf6a4ed 100644 --- a/crates/wzp-android/src/jni_bridge.rs +++ b/crates/wzp-android/src/jni_bridge.rs @@ -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)] diff --git a/desktop/src-tauri/src/android_audio.rs b/desktop/src-tauri/src/android_audio.rs index 20baee9..3f4d960 100644 --- a/desktop/src-tauri/src/android_audio.rs +++ b/desktop/src-tauri/src/android_audio.rs @@ -96,3 +96,134 @@ pub fn is_speakerphone_on() -> Result { .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 { + 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 { + 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) +} diff --git a/desktop/src-tauri/src/lib.rs b/desktop/src-tauri/src/lib.rs index faf3bc4..d8750eb 100644 --- a/desktop/src-tauri/src/lib.rs +++ b/desktop/src-tauri/src/lib.rs @@ -775,6 +775,71 @@ async fn is_speakerphone_on() -> Result { } } +// ─── 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 { + #[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 { + #[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, diff --git a/desktop/src/main.ts b/desktop/src/main.ts index be6b4c4..f74d41f 100644 --- a/desktop/src/main.ts +++ b/desktop/src/main.ts @@ -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("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("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("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); diff --git a/desktop/src/style.css b/desktop/src/style.css index 362dbfd..d0f321b 100644 --- a/desktop/src/style.css +++ b/desktop/src/style.css @@ -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 */ +} diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index bff44ae..cb1d512 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -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) +``` diff --git a/docs/DESIGN.md b/docs/DESIGN.md index dc766de..703f8fc 100644 --- a/docs/DESIGN.md +++ b/docs/DESIGN.md @@ -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) diff --git a/docs/PRD-bluetooth-audio.md b/docs/PRD-bluetooth-audio.md new file mode 100644 index 0000000..ffb8e43 --- /dev/null +++ b/docs/PRD-bluetooth-audio.md @@ -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`, `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 diff --git a/docs/PRD-network-awareness.md b/docs/PRD-network-awareness.md new file mode 100644 index 0000000..30c30fb --- /dev/null +++ b/docs/PRD-network-awareness.md @@ -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` diff --git a/scripts/build-tauri-android.sh b/scripts/build-tauri-android.sh index 368b1b8..0a0e110 100755 --- a/scripts/build-tauri-android.sh +++ b/scripts/build-tauri-android.sh @@ -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