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
|
||||
|
||||
Reference in New Issue
Block a user