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:
@@ -72,18 +72,22 @@ class MainActivity : TauriActivity() {
|
|||||||
* STREAM_VOICE_CALL volume is cranked to max since the in-call volume
|
* STREAM_VOICE_CALL volume is cranked to max since the in-call volume
|
||||||
* slider is separate from media volume on most devices.
|
* 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() {
|
private fun configureAudioForCall() {
|
||||||
try {
|
try {
|
||||||
val am = getSystemService(Context.AUDIO_SERVICE) as AudioManager
|
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)}/" +
|
"voiceVol=${am.getStreamVolume(AudioManager.STREAM_VOICE_CALL)}/" +
|
||||||
"${am.getStreamMaxVolume(AudioManager.STREAM_VOICE_CALL)} " +
|
"${am.getStreamMaxVolume(AudioManager.STREAM_VOICE_CALL)} " +
|
||||||
"musicVol=${am.getStreamVolume(AudioManager.STREAM_MUSIC)}/" +
|
"musicVol=${am.getStreamVolume(AudioManager.STREAM_MUSIC)}/" +
|
||||||
"${am.getStreamMaxVolume(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
|
// Crank both voice-call and music volumes so nothing silent slips
|
||||||
// through regardless of which stream actually ends up driving.
|
// through regardless of which stream actually ends up driving.
|
||||||
val maxVoice = am.getStreamMaxVolume(AudioManager.STREAM_VOICE_CALL)
|
val maxVoice = am.getStreamMaxVolume(AudioManager.STREAM_VOICE_CALL)
|
||||||
@@ -91,9 +95,7 @@ class MainActivity : TauriActivity() {
|
|||||||
val maxMusic = am.getStreamMaxVolume(AudioManager.STREAM_MUSIC)
|
val maxMusic = am.getStreamMaxVolume(AudioManager.STREAM_MUSIC)
|
||||||
am.setStreamVolume(AudioManager.STREAM_MUSIC, maxMusic, 0)
|
am.setStreamVolume(AudioManager.STREAM_MUSIC, maxMusic, 0)
|
||||||
|
|
||||||
Log.i(TAG, "audio state after: mode=${am.mode} speaker=${am.isSpeakerphoneOn} " +
|
Log.i(TAG, "volumes set: voiceVol=$maxVoice musicVol=$maxMusic (mode left at ${am.mode})")
|
||||||
"voiceVol=${am.getStreamVolume(AudioManager.STREAM_VOICE_CALL)}/$maxVoice " +
|
|
||||||
"musicVol=${am.getStreamVolume(AudioManager.STREAM_MUSIC)}/$maxMusic")
|
|
||||||
} catch (e: Throwable) {
|
} catch (e: Throwable) {
|
||||||
Log.e(TAG, "configureAudioForCall failed: ${e.message}", e)
|
Log.e(TAG, "configureAudioForCall failed: ${e.message}", e)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -57,11 +57,37 @@ fn audio_manager<'local>(
|
|||||||
Ok(am)
|
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`).
|
/// 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> {
|
pub fn set_speakerphone(on: bool) -> Result<(), String> {
|
||||||
let (vm, activity) = jvm_and_activity()?;
|
let (vm, activity) = jvm_and_activity()?;
|
||||||
let mut env = vm
|
let mut env = vm
|
||||||
|
|||||||
@@ -436,6 +436,16 @@ impl CallEngine {
|
|||||||
// hitting a "device busy" on some HALs.
|
// hitting a "device busy" on some HALs.
|
||||||
tokio::time::sleep(std::time::Duration::from_millis(50)).await;
|
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();
|
let t_pre_audio = call_t0.elapsed().as_millis();
|
||||||
if let Err(code) = crate::wzp_native::audio_start() {
|
if let Err(code) = crate::wzp_native::audio_start() {
|
||||||
return Err(anyhow::anyhow!("wzp_native_audio_start failed: code {code}"));
|
return Err(anyhow::anyhow!("wzp_native_audio_start failed: code {code}"));
|
||||||
@@ -1490,6 +1500,12 @@ impl CallEngine {
|
|||||||
#[cfg(target_os = "android")]
|
#[cfg(target_os = "android")]
|
||||||
{
|
{
|
||||||
crate::wzp_native::audio_stop();
|
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}");
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user