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:
@@ -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.
|
||||
|
||||
141
android/app/src/main/java/com/wzp/net/NetworkMonitor.kt
Normal file
141
android/app/src/main/java/com/wzp/net/NetworkMonitor.kt
Normal 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
|
||||
}
|
||||
}
|
||||
@@ -5,6 +5,7 @@ import android.util.Log
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import com.wzp.audio.AudioPipeline
|
||||
import com.wzp.audio.AudioRoute
|
||||
import com.wzp.audio.AudioRouteManager
|
||||
import com.wzp.data.SettingsRepository
|
||||
import com.wzp.debug.DebugReporter
|
||||
@@ -12,6 +13,7 @@ import com.wzp.engine.CallStats
|
||||
import com.wzp.service.CallService
|
||||
import com.wzp.engine.WzpCallback
|
||||
import com.wzp.engine.WzpEngine
|
||||
import com.wzp.net.NetworkMonitor
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.delay
|
||||
@@ -43,6 +45,7 @@ class CallViewModel : ViewModel(), WzpCallback {
|
||||
private var engineInitialized = false
|
||||
private var audioPipeline: AudioPipeline? = null
|
||||
private var audioRouteManager: AudioRouteManager? = null
|
||||
private var networkMonitor: NetworkMonitor? = null
|
||||
private var audioStarted = false
|
||||
private var appContext: Context? = null
|
||||
private var settings: SettingsRepository? = null
|
||||
@@ -60,6 +63,9 @@ class CallViewModel : ViewModel(), WzpCallback {
|
||||
private val _isSpeaker = MutableStateFlow(false)
|
||||
val isSpeaker: StateFlow<Boolean> = _isSpeaker.asStateFlow()
|
||||
|
||||
private val _audioRoute = MutableStateFlow(AudioRoute.EARPIECE)
|
||||
val audioRoute: StateFlow<AudioRoute> = _audioRoute.asStateFlow()
|
||||
|
||||
private val _stats = MutableStateFlow(CallStats())
|
||||
val stats: StateFlow<CallStats> = _stats.asStateFlow()
|
||||
|
||||
@@ -226,7 +232,19 @@ class CallViewModel : ViewModel(), WzpCallback {
|
||||
audioPipeline = AudioPipeline(appCtx)
|
||||
}
|
||||
if (audioRouteManager == null) {
|
||||
audioRouteManager = AudioRouteManager(appCtx)
|
||||
audioRouteManager = AudioRouteManager(appCtx).also { arm ->
|
||||
arm.onRouteChanged = { route ->
|
||||
_audioRoute.value = route
|
||||
_isSpeaker.value = (route == AudioRoute.SPEAKER)
|
||||
}
|
||||
}
|
||||
}
|
||||
if (networkMonitor == null) {
|
||||
networkMonitor = NetworkMonitor(appCtx).also { nm ->
|
||||
nm.onNetworkChanged = { type, bw ->
|
||||
engine?.onNetworkChanged(type, bw)
|
||||
}
|
||||
}
|
||||
}
|
||||
if (debugReporter == null) {
|
||||
debugReporter = DebugReporter(appCtx)
|
||||
@@ -607,6 +625,27 @@ class CallViewModel : ViewModel(), WzpCallback {
|
||||
audioRouteManager?.setSpeaker(newSpeaker)
|
||||
}
|
||||
|
||||
/** Cycle audio output: Earpiece → Speaker → Bluetooth (if available) → Earpiece. */
|
||||
fun cycleAudioRoute() {
|
||||
val routes = audioRouteManager?.availableRoutes() ?: return
|
||||
val currentIdx = routes.indexOf(_audioRoute.value)
|
||||
val next = routes[(currentIdx + 1) % routes.size]
|
||||
when (next) {
|
||||
AudioRoute.EARPIECE -> {
|
||||
audioRouteManager?.setBluetoothSco(false)
|
||||
audioRouteManager?.setSpeaker(false)
|
||||
}
|
||||
AudioRoute.SPEAKER -> {
|
||||
audioRouteManager?.setSpeaker(true)
|
||||
}
|
||||
AudioRoute.BLUETOOTH -> {
|
||||
audioRouteManager?.setBluetoothSco(true)
|
||||
}
|
||||
}
|
||||
_audioRoute.value = next
|
||||
_isSpeaker.value = (next == AudioRoute.SPEAKER)
|
||||
}
|
||||
|
||||
fun clearError() { _errorMessage.value = null }
|
||||
|
||||
fun sendDebugReport() {
|
||||
@@ -661,6 +700,7 @@ class CallViewModel : ViewModel(), WzpCallback {
|
||||
it.start(e)
|
||||
}
|
||||
audioRouteManager?.register()
|
||||
networkMonitor?.register()
|
||||
audioStarted = true
|
||||
}
|
||||
|
||||
@@ -668,8 +708,10 @@ class CallViewModel : ViewModel(), WzpCallback {
|
||||
if (!audioStarted) return
|
||||
audioPipeline?.stop() // sets running=false; DON'T null — teardown needs awaitDrain()
|
||||
audioRouteManager?.unregister()
|
||||
networkMonitor?.unregister()
|
||||
audioRouteManager?.setSpeaker(false)
|
||||
_isSpeaker.value = false
|
||||
_audioRoute.value = AudioRoute.EARPIECE
|
||||
audioStarted = false
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -99,6 +99,9 @@ pub(crate) struct EngineState {
|
||||
/// QUIC transport handle — stored so stop_call() can close it immediately,
|
||||
/// triggering relay-side leave + RoomUpdate broadcast.
|
||||
pub quic_transport: Mutex<Option<Arc<wzp_transport::QuinnTransport>>>,
|
||||
/// Network type from Android ConnectivityManager, polled by recv task.
|
||||
/// 0xFF = no change pending; 0-5 = NetworkContext ordinal.
|
||||
pub pending_network_type: AtomicU8,
|
||||
}
|
||||
|
||||
pub struct WzpEngine {
|
||||
@@ -120,6 +123,7 @@ impl WzpEngine {
|
||||
playout_ring: AudioRing::new(),
|
||||
audio_level_rms: AtomicU32::new(0),
|
||||
quic_transport: Mutex::new(None),
|
||||
pending_network_type: AtomicU8::new(PROFILE_NO_CHANGE),
|
||||
});
|
||||
Self {
|
||||
state,
|
||||
@@ -404,6 +408,13 @@ impl WzpEngine {
|
||||
|
||||
pub fn force_profile(&self, _profile: QualityProfile) {}
|
||||
|
||||
/// Signal a network transport change from Android ConnectivityManager.
|
||||
/// Stores the type atomically; the recv task polls it on each packet.
|
||||
pub fn on_network_changed(&self, network_type: u8, bandwidth_kbps: u32) {
|
||||
info!(network_type, bandwidth_kbps, "on_network_changed");
|
||||
self.state.pending_network_type.store(network_type, Ordering::Release);
|
||||
}
|
||||
|
||||
pub fn get_stats(&self) -> CallStats {
|
||||
let mut stats = self.state.stats.lock().unwrap().clone();
|
||||
if let Some(start) = self.call_start {
|
||||
@@ -871,6 +882,23 @@ async fn run_call(
|
||||
);
|
||||
}
|
||||
|
||||
// Check for network transport change from ConnectivityManager
|
||||
{
|
||||
let net = state.pending_network_type.swap(PROFILE_NO_CHANGE, Ordering::Acquire);
|
||||
if net != PROFILE_NO_CHANGE {
|
||||
use wzp_proto::NetworkContext;
|
||||
let ctx = match net {
|
||||
0 => NetworkContext::WiFi,
|
||||
1 => NetworkContext::CellularLte,
|
||||
2 => NetworkContext::Cellular5g,
|
||||
3 => NetworkContext::Cellular3g,
|
||||
_ => NetworkContext::Unknown,
|
||||
};
|
||||
quality_ctrl.signal_network_change(ctx);
|
||||
info!(?ctx, "quality controller: network context updated");
|
||||
}
|
||||
}
|
||||
|
||||
// Adaptive quality: ingest quality reports from relay
|
||||
if auto_profile {
|
||||
if let Some(ref qr) = pkt.quality_report {
|
||||
|
||||
@@ -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)]
|
||||
|
||||
@@ -96,3 +96,134 @@ pub fn is_speakerphone_on() -> Result<bool, String> {
|
||||
.map_err(|e| format!("isSpeakerphoneOn: {e}"))?;
|
||||
Ok(on)
|
||||
}
|
||||
|
||||
// ─── Bluetooth SCO routing ──────────────────────────────────────────────────
|
||||
|
||||
/// Start Bluetooth SCO (Synchronous Connection Oriented) audio routing.
|
||||
///
|
||||
/// Turns off the loudspeaker, then opens the SCO link so both capture and
|
||||
/// playout move to the connected Bluetooth headset. Requires that a SCO-
|
||||
/// capable device is paired and connected (check [`is_bluetooth_available`]
|
||||
/// first). The caller must restart Oboe streams after this call.
|
||||
#[allow(deprecated)]
|
||||
pub fn start_bluetooth_sco() -> Result<(), String> {
|
||||
let (vm, activity) = jvm_and_activity()?;
|
||||
let mut env = vm
|
||||
.attach_current_thread()
|
||||
.map_err(|e| format!("attach_current_thread: {e}"))?;
|
||||
let am = audio_manager(&mut env, &activity)?;
|
||||
|
||||
// Ensure speaker is off — mutually exclusive with SCO.
|
||||
env.call_method(
|
||||
&am,
|
||||
"setSpeakerphoneOn",
|
||||
"(Z)V",
|
||||
&[JValue::Bool(0)],
|
||||
)
|
||||
.map_err(|e| format!("setSpeakerphoneOn(false): {e}"))?;
|
||||
|
||||
env.call_method(&am, "startBluetoothSco", "()V", &[])
|
||||
.map_err(|e| format!("startBluetoothSco: {e}"))?;
|
||||
|
||||
env.call_method(
|
||||
&am,
|
||||
"setBluetoothScoOn",
|
||||
"(Z)V",
|
||||
&[JValue::Bool(1)],
|
||||
)
|
||||
.map_err(|e| format!("setBluetoothScoOn(true): {e}"))?;
|
||||
|
||||
tracing::info!("AudioManager: Bluetooth SCO started");
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Stop Bluetooth SCO audio routing, returning audio to the earpiece.
|
||||
///
|
||||
/// Safe to call even if SCO is not currently active (no-ops in that case).
|
||||
/// The caller must restart Oboe streams after this call.
|
||||
#[allow(deprecated)]
|
||||
pub fn stop_bluetooth_sco() -> Result<(), String> {
|
||||
let (vm, activity) = jvm_and_activity()?;
|
||||
let mut env = vm
|
||||
.attach_current_thread()
|
||||
.map_err(|e| format!("attach_current_thread: {e}"))?;
|
||||
let am = audio_manager(&mut env, &activity)?;
|
||||
|
||||
let is_on = env
|
||||
.call_method(&am, "isBluetoothScoOn", "()Z", &[])
|
||||
.and_then(|v| v.z())
|
||||
.unwrap_or(false);
|
||||
|
||||
if is_on {
|
||||
env.call_method(
|
||||
&am,
|
||||
"setBluetoothScoOn",
|
||||
"(Z)V",
|
||||
&[JValue::Bool(0)],
|
||||
)
|
||||
.map_err(|e| format!("setBluetoothScoOn(false): {e}"))?;
|
||||
|
||||
env.call_method(&am, "stopBluetoothSco", "()V", &[])
|
||||
.map_err(|e| format!("stopBluetoothSco: {e}"))?;
|
||||
}
|
||||
|
||||
tracing::info!(was_on = is_on, "AudioManager: Bluetooth SCO stopped");
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Query whether Bluetooth SCO audio is currently active.
|
||||
#[allow(deprecated)]
|
||||
pub fn is_bluetooth_sco_on() -> Result<bool, String> {
|
||||
let (vm, activity) = jvm_and_activity()?;
|
||||
let mut env = vm
|
||||
.attach_current_thread()
|
||||
.map_err(|e| format!("attach_current_thread: {e}"))?;
|
||||
let am = audio_manager(&mut env, &activity)?;
|
||||
|
||||
env.call_method(&am, "isBluetoothScoOn", "()Z", &[])
|
||||
.and_then(|v| v.z())
|
||||
.map_err(|e| format!("isBluetoothScoOn: {e}"))
|
||||
}
|
||||
|
||||
/// Check whether a Bluetooth SCO-capable device is currently connected.
|
||||
///
|
||||
/// Iterates `AudioManager.getDevices(GET_DEVICES_OUTPUTS)` and looks for
|
||||
/// `TYPE_BLUETOOTH_SCO` (7).
|
||||
pub fn is_bluetooth_available() -> Result<bool, String> {
|
||||
let (vm, activity) = jvm_and_activity()?;
|
||||
let mut env = vm
|
||||
.attach_current_thread()
|
||||
.map_err(|e| format!("attach_current_thread: {e}"))?;
|
||||
let am = audio_manager(&mut env, &activity)?;
|
||||
|
||||
// AudioManager.GET_DEVICES_OUTPUTS = 2
|
||||
let devices = env
|
||||
.call_method(
|
||||
&am,
|
||||
"getDevices",
|
||||
"(I)[Landroid/media/AudioDeviceInfo;",
|
||||
&[JValue::Int(2)],
|
||||
)
|
||||
.and_then(|v| v.l())
|
||||
.map_err(|e| format!("getDevices(OUTPUTS): {e}"))?;
|
||||
|
||||
let arr = jni::objects::JObjectArray::from(devices);
|
||||
let len = env
|
||||
.get_array_length(&arr)
|
||||
.map_err(|e| format!("get_array_length: {e}"))?;
|
||||
|
||||
for i in 0..len {
|
||||
let device = env
|
||||
.get_object_array_element(&arr, i)
|
||||
.map_err(|e| format!("get_object_array_element({i}): {e}"))?;
|
||||
let device_type = env
|
||||
.call_method(&device, "getType", "()I", &[])
|
||||
.and_then(|v| v.i())
|
||||
.unwrap_or(0);
|
||||
// TYPE_BLUETOOTH_SCO = 7
|
||||
if device_type == 7 {
|
||||
return Ok(true);
|
||||
}
|
||||
}
|
||||
Ok(false)
|
||||
}
|
||||
|
||||
@@ -775,6 +775,71 @@ async fn is_speakerphone_on() -> Result<bool, String> {
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Bluetooth SCO routing (Android-specific, no-op on desktop) ─────────────
|
||||
|
||||
/// Enable or disable Bluetooth SCO audio routing. Like speakerphone toggling,
|
||||
/// this requires an Oboe stream restart so AAudio picks up the new route.
|
||||
#[tauri::command]
|
||||
#[allow(unused_variables)]
|
||||
async fn set_bluetooth_sco(on: bool) -> Result<(), String> {
|
||||
#[cfg(target_os = "android")]
|
||||
{
|
||||
if on {
|
||||
android_audio::start_bluetooth_sco()?;
|
||||
} else {
|
||||
android_audio::stop_bluetooth_sco()?;
|
||||
}
|
||||
if wzp_native::is_loaded() && wzp_native::audio_is_running() {
|
||||
tracing::info!(on, "set_bluetooth_sco: restarting Oboe for route change");
|
||||
tokio::task::spawn_blocking(|| {
|
||||
wzp_native::audio_stop();
|
||||
wzp_native::audio_start()
|
||||
.map_err(|code| format!("audio_start after BT toggle: code {code}"))
|
||||
})
|
||||
.await
|
||||
.map_err(|e| format!("spawn_blocking join: {e}"))??;
|
||||
tracing::info!("set_bluetooth_sco: Oboe restarted");
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
#[cfg(not(target_os = "android"))]
|
||||
{
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
/// Check whether a Bluetooth SCO device is currently connected and available.
|
||||
#[tauri::command]
|
||||
async fn is_bluetooth_available() -> Result<bool, String> {
|
||||
#[cfg(target_os = "android")]
|
||||
{
|
||||
android_audio::is_bluetooth_available()
|
||||
}
|
||||
#[cfg(not(target_os = "android"))]
|
||||
{
|
||||
Ok(false)
|
||||
}
|
||||
}
|
||||
|
||||
/// Return the current audio route as a string: "bluetooth", "speaker", or "earpiece".
|
||||
#[tauri::command]
|
||||
async fn get_audio_route() -> Result<String, String> {
|
||||
#[cfg(target_os = "android")]
|
||||
{
|
||||
if android_audio::is_bluetooth_sco_on()? {
|
||||
return Ok("bluetooth".into());
|
||||
}
|
||||
if android_audio::is_speakerphone_on()? {
|
||||
return Ok("speaker".into());
|
||||
}
|
||||
Ok("earpiece".into())
|
||||
}
|
||||
#[cfg(not(target_os = "android"))]
|
||||
{
|
||||
Ok("earpiece".into())
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Call history commands ───────────────────────────────────────────────────
|
||||
|
||||
#[tauri::command]
|
||||
@@ -1892,6 +1957,7 @@ pub fn run() {
|
||||
hangup_call,
|
||||
deregister,
|
||||
set_speakerphone, is_speakerphone_on,
|
||||
set_bluetooth_sco, is_bluetooth_available, get_audio_route,
|
||||
get_call_history, get_recent_contacts, clear_call_history,
|
||||
set_dred_verbose_logs, get_dred_verbose_logs,
|
||||
set_call_debug_logs, get_call_debug_logs,
|
||||
|
||||
@@ -872,12 +872,11 @@ function showCallScreen() {
|
||||
}
|
||||
callStatus.className = "status-dot";
|
||||
statusInterval = window.setInterval(pollStatus, 250);
|
||||
// Sync the Speaker/Earpiece label with the OS state (Android only; on
|
||||
// desktop the command is a no-op returning false so we land on "Earpiece"
|
||||
// which is fine because desktop has no routing concept).
|
||||
invoke<boolean>("is_speakerphone_on")
|
||||
.then((on) => { speakerphoneOn = !!on; updateSpkLabel(); })
|
||||
.catch(() => { speakerphoneOn = false; updateSpkLabel(); });
|
||||
// Sync the audio route label with the OS state (Android only; on desktop
|
||||
// get_audio_route returns "earpiece" so we land on the default).
|
||||
invoke<string>("get_audio_route")
|
||||
.then((route) => { currentAudioRoute = (route as AudioRoute) || "earpiece"; updateRouteLabel(); })
|
||||
.catch(() => { currentAudioRoute = "earpiece"; updateRouteLabel(); });
|
||||
}
|
||||
|
||||
function showConnectScreen() {
|
||||
@@ -898,38 +897,74 @@ micBtn.addEventListener("click", async () => {
|
||||
try { const m: boolean = await invoke("toggle_mic"); micBtn.classList.toggle("muted", m); micIcon.textContent = m ? "Mic Off" : "Mic"; } catch {}
|
||||
});
|
||||
|
||||
// Speaker routing (Android) — toggles AudioManager.setSpeakerphoneOn + then
|
||||
// stops and restarts the Oboe streams so AAudio reconfigures with the new
|
||||
// routing. The Rust-side Tauri command handles the restart, we just swap
|
||||
// the button label.
|
||||
// Audio routing (Android) — cycles between earpiece, speaker, and Bluetooth
|
||||
// SCO. Each transition calls the corresponding Tauri command which sets the
|
||||
// AudioManager state and restarts Oboe streams so AAudio picks up the new
|
||||
// route. On desktop all commands are no-ops.
|
||||
//
|
||||
// Earpiece is NOT a "muted" state, so DO NOT add the `.muted` CSS class
|
||||
// (which would tint the button red); that was a bug in 0178cbd that made
|
||||
// earpiece mode look like playback was off. A separate `.speaker-on` class
|
||||
// is available for css styling if we want to visually indicate loud mode.
|
||||
let speakerphoneOn = false;
|
||||
let speakerphoneBusy = false;
|
||||
function updateSpkLabel() {
|
||||
spkBtn.classList.toggle("speaker-on", speakerphoneOn);
|
||||
// earpiece mode look like playback was off.
|
||||
type AudioRoute = "earpiece" | "speaker" | "bluetooth";
|
||||
let currentAudioRoute: AudioRoute = "earpiece";
|
||||
let routeBusy = false;
|
||||
|
||||
function updateRouteLabel() {
|
||||
spkBtn.classList.remove("speaker-on", "bt-on");
|
||||
spkBtn.classList.remove("muted");
|
||||
spkIcon.textContent = speakerphoneOn ? "🔊 Speaker" : "🔈 Earpiece";
|
||||
switch (currentAudioRoute) {
|
||||
case "speaker":
|
||||
spkIcon.textContent = "🔊 Speaker";
|
||||
spkBtn.classList.add("speaker-on");
|
||||
break;
|
||||
case "bluetooth":
|
||||
spkIcon.textContent = "🎧 BT";
|
||||
spkBtn.classList.add("bt-on");
|
||||
break;
|
||||
default:
|
||||
spkIcon.textContent = "🔈 Earpiece";
|
||||
break;
|
||||
}
|
||||
}
|
||||
spkBtn.addEventListener("click", async () => {
|
||||
if (speakerphoneBusy) return; // debounce — the restart takes ~60ms
|
||||
speakerphoneBusy = true;
|
||||
const next = !speakerphoneOn;
|
||||
|
||||
async function cycleAudioRoute() {
|
||||
if (routeBusy) return; // debounce — Oboe restart takes ~60-400ms
|
||||
routeBusy = true;
|
||||
spkBtn.disabled = true;
|
||||
try {
|
||||
await invoke("set_speakerphone", { on: next });
|
||||
speakerphoneOn = next;
|
||||
updateSpkLabel();
|
||||
const btAvailable = await invoke<boolean>("is_bluetooth_available");
|
||||
const routes: AudioRoute[] = btAvailable
|
||||
? ["earpiece", "speaker", "bluetooth"]
|
||||
: ["earpiece", "speaker"];
|
||||
const idx = routes.indexOf(currentAudioRoute);
|
||||
const next = routes[(idx + 1) % routes.length];
|
||||
|
||||
// Tear down current route
|
||||
if (currentAudioRoute === "bluetooth") {
|
||||
await invoke("set_bluetooth_sco", { on: false });
|
||||
}
|
||||
// Activate next route
|
||||
if (next === "speaker") {
|
||||
await invoke("set_speakerphone", { on: true });
|
||||
} else if (next === "bluetooth") {
|
||||
await invoke("set_speakerphone", { on: false });
|
||||
await invoke("set_bluetooth_sco", { on: true });
|
||||
} else {
|
||||
// earpiece — turn everything off
|
||||
await invoke("set_speakerphone", { on: false });
|
||||
}
|
||||
|
||||
currentAudioRoute = next;
|
||||
updateRouteLabel();
|
||||
} catch (e) {
|
||||
console.error("set_speakerphone failed:", e);
|
||||
console.error("cycleAudioRoute failed:", e);
|
||||
} finally {
|
||||
spkBtn.disabled = false;
|
||||
speakerphoneBusy = false;
|
||||
routeBusy = false;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
spkBtn.addEventListener("click", cycleAudioRoute);
|
||||
hangupBtn.addEventListener("click", async () => {
|
||||
userDisconnected = true;
|
||||
// Use the new hangup_call command instead of raw disconnect —
|
||||
@@ -1002,7 +1037,7 @@ async function pollStatus() {
|
||||
micBtn.classList.toggle("muted", st.mic_muted);
|
||||
micIcon.textContent = st.mic_muted ? "Mic Off" : "Mic";
|
||||
// NB: spkBtn label is driven by the Android audio routing state
|
||||
// (speakerphoneOn / updateSpkLabel), not by the engine's spk_muted.
|
||||
// (currentAudioRoute / updateRouteLabel), not by the engine's spk_muted.
|
||||
// Skip that here so pollStatus doesn't clobber the routing UI.
|
||||
callTimer.textContent = formatDuration(st.call_duration_secs);
|
||||
|
||||
|
||||
@@ -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 */
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
```
|
||||
|
||||
@@ -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)
|
||||
|
||||
98
docs/PRD-bluetooth-audio.md
Normal file
98
docs/PRD-bluetooth-audio.md
Normal 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
|
||||
129
docs/PRD-network-awareness.md
Normal file
129
docs/PRD-network-awareness.md
Normal 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`
|
||||
@@ -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)
|
||||
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 ">>> 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
|
||||
cp "$NDK_LIBCXX" "$JNI_ABI_DIR/"
|
||||
ls -lh "$JNI_ABI_DIR/libc++_shared.so"
|
||||
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
|
||||
fi
|
||||
fi
|
||||
fi
|
||||
done
|
||||
|
||||
echo ">>> cargo tauri android build ${PROFILE_FLAG} --target aarch64 --apk"
|
||||
cargo tauri android build ${PROFILE_FLAG} --target aarch64 --apk
|
||||
# ─── 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
|
||||
|
||||
Reference in New Issue
Block a user