fix(audio): defer MODE_IN_COMMUNICATION to call start, restore on end

Root cause: MainActivity set MODE_IN_COMMUNICATION at app launch,
hijacking system audio routing immediately — BT A2DP music dropped to
earpiece, and the pre-existing communication mode confused subsequent
setCommunicationDevice calls for BT SCO.

Fix: MainActivity now only sets volumes. MODE_IN_COMMUNICATION is set
via JNI right before Oboe audio_start() in CallEngine, and MODE_NORMAL
is restored after audio_stop() when the call ends.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Siavash Sameni
2026-04-12 17:29:59 +04:00
parent a37c8b30fe
commit 15c237ceea
3 changed files with 55 additions and 11 deletions

View File

@@ -72,18 +72,22 @@ class MainActivity : TauriActivity() {
* STREAM_VOICE_CALL volume is cranked to max since the in-call volume
* slider is separate from media volume on most devices.
*/
/**
* Pre-flight: only set volumes. Do NOT set MODE_IN_COMMUNICATION here —
* that hijacks the entire audio routing (music stops, BT A2DP drops to
* earpiece) even before a call starts. The Rust side sets the mode via
* JNI when the call engine actually starts, and restores MODE_NORMAL
* when the call ends.
*/
private fun configureAudioForCall() {
try {
val am = getSystemService(Context.AUDIO_SERVICE) as AudioManager
Log.i(TAG, "audio state before: mode=${am.mode} speaker=${am.isSpeakerphoneOn} " +
Log.i(TAG, "audio state: mode=${am.mode} speaker=${am.isSpeakerphoneOn} " +
"voiceVol=${am.getStreamVolume(AudioManager.STREAM_VOICE_CALL)}/" +
"${am.getStreamMaxVolume(AudioManager.STREAM_VOICE_CALL)} " +
"musicVol=${am.getStreamVolume(AudioManager.STREAM_MUSIC)}/" +
"${am.getStreamMaxVolume(AudioManager.STREAM_MUSIC)}")
am.mode = AudioManager.MODE_IN_COMMUNICATION
am.isSpeakerphoneOn = false // default: handset / earpiece
// Crank both voice-call and music volumes so nothing silent slips
// through regardless of which stream actually ends up driving.
val maxVoice = am.getStreamMaxVolume(AudioManager.STREAM_VOICE_CALL)
@@ -91,9 +95,7 @@ class MainActivity : TauriActivity() {
val maxMusic = am.getStreamMaxVolume(AudioManager.STREAM_MUSIC)
am.setStreamVolume(AudioManager.STREAM_MUSIC, maxMusic, 0)
Log.i(TAG, "audio state after: mode=${am.mode} speaker=${am.isSpeakerphoneOn} " +
"voiceVol=${am.getStreamVolume(AudioManager.STREAM_VOICE_CALL)}/$maxVoice " +
"musicVol=${am.getStreamVolume(AudioManager.STREAM_MUSIC)}/$maxMusic")
Log.i(TAG, "volumes set: voiceVol=$maxVoice musicVol=$maxMusic (mode left at ${am.mode})")
} catch (e: Throwable) {
Log.e(TAG, "configureAudioForCall failed: ${e.message}", e)
}

View File

@@ -57,11 +57,37 @@ fn audio_manager<'local>(
Ok(am)
}
/// Set `AudioManager.MODE_IN_COMMUNICATION`. Call when a VoIP call starts.
/// This tells the audio policy to route through the communication device
/// path (earpiece/BT SCO) instead of the media path (speaker/BT A2DP).
pub fn set_audio_mode_communication() -> 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)?;
// MODE_IN_COMMUNICATION = 3
env.call_method(&am, "setMode", "(I)V", &[JValue::Int(3)])
.map_err(|e| format!("setMode(MODE_IN_COMMUNICATION): {e}"))?;
tracing::info!("AudioManager: mode set to MODE_IN_COMMUNICATION");
Ok(())
}
/// Restore `AudioManager.MODE_NORMAL`. Call when a VoIP call ends.
pub fn set_audio_mode_normal() -> 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)?;
// MODE_NORMAL = 0
env.call_method(&am, "setMode", "(I)V", &[JValue::Int(0)])
.map_err(|e| format!("setMode(MODE_NORMAL): {e}"))?;
tracing::info!("AudioManager: mode set to MODE_NORMAL");
Ok(())
}
/// Switch between loud speaker (`true`) and earpiece/handset (`false`).
///
/// Calls `AudioManager.setSpeakerphoneOn(on)` on the JVM. Requires that
/// the audio mode is already `MODE_IN_COMMUNICATION` — MainActivity.kt
/// sets this at startup, so by the time a call is up this is always true.
pub fn set_speakerphone(on: bool) -> Result<(), String> {
let (vm, activity) = jvm_and_activity()?;
let mut env = vm

View File

@@ -436,6 +436,16 @@ impl CallEngine {
// hitting a "device busy" on some HALs.
tokio::time::sleep(std::time::Duration::from_millis(50)).await;
// Set MODE_IN_COMMUNICATION right before audio starts — NOT at
// app launch. Setting it early hijacks system audio routing
// (music drops from BT A2DP to earpiece, etc.).
#[cfg(target_os = "android")]
{
if let Err(e) = crate::android_audio::set_audio_mode_communication() {
warn!("set_audio_mode_communication failed: {e}");
}
}
let t_pre_audio = call_t0.elapsed().as_millis();
if let Err(code) = crate::wzp_native::audio_start() {
return Err(anyhow::anyhow!("wzp_native_audio_start failed: code {code}"));
@@ -1490,6 +1500,12 @@ impl CallEngine {
#[cfg(target_os = "android")]
{
crate::wzp_native::audio_stop();
// Restore MODE_NORMAL so other apps' audio (music, etc.)
// routes normally again. Without this, the system stays in
// communication mode and BT A2DP music goes to earpiece.
if let Err(e) = crate::android_audio::set_audio_mode_normal() {
tracing::warn!("set_audio_mode_normal failed: {e}");
}
}
}
}