🔊 How to Implement Audio Output Switching During the Call on Android App? 🤖

🔊 How to Implement Audio Output Switching During the Call on Android App? 🤖

·

6 min read

Seamless and timely switching between the sound output devices on Android is a feature that is usually taken for granted, but the lack of it (or problems with it) is very annoying. Today we will analyze how to implement such switching in Android ringtones, starting from the manual switching by the user to the automatic switching when headsets are connected. At the same time, let's talk about pausing the rest of the audio system for the duration of the call. This implementation is suitable for almost all calling applications since it operates at the system level rather than the call engine level, e.g., WebRTC.

Audio output device management

All management of Android sound output devices is implemented through the system's AudioManager. To work with it you need to add permission to AndroidManifest.xml:

<uses-permission android:name="android.permission.MODIFY_AUDIO_SETTINGS" />

First of all, when a call starts in our app, it is highly recommended to capture the audio focus -- let the system know that the user is now communicating with someone, and it is best not to be distracted by sounds from other apps. For example, if the user was listening to music, but received a call and answered -- the music will be paused for the duration of the call.

There are two mechanisms of audio focus request -- the old one is deprecated, and the new one is available since Android 8.0. We implement for all versions of the system:

// Receiving an AudioManager sample
val audioManager = context.getSystemService(Context.AUDIO_SERVICE) as AudioManager
// We need a "request" for the new approach. Let's generate it for versions >=8.0 and leave null for older ones
@RequiresApi(Build.VERSION_CODES.O)
private fun getAudioFocusRequest() =
   AudioFocusRequest.Builder(AudioManager.AUDIOFOCUS_GAIN).build()


// Focus request
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
    // Use the generated request
    audioManager.requestAudioFocus(getAudioFocusRequest())
} else {
    audioManager.requestAudioFocus(
        // Listener of receiving focus. Let's leave it empty for the sake of simpleness
        { },
        // Requesting a call focus
        AudioAttributes.CONTENT_TYPE_SPEECH,
        AudioManager.AUDIOFOCUS_GAIN
    )
}

It is important to specify the most appropriate ContentType and Usage -- based on these, the system determines which of the custom volume settings to use (media volume or ringer volume) and what to do with the other audio sources (mute, pause, or allow to run as before).

val savedAudioMode = audioManager.mode
val savedIsSpeakerOn = audioManager.isSpeakerphoneOn
val savedIsMicrophoneMuted = audioManager.isMicrophoneMute

Great, we've got audio focus. It is highly recommended to save the original AudioManager settings right away before changing anything - this will allow us to restore it to its previous state when the call is over. You should agree that it would be very inconvenient if one application's volume control would affect all the others

Now we can start setting our defaults. It may depend on the type of call (usually audio calls are on "speakerphone" and video calls are on "speakerphone"), on the user settings in the application or just on the last used speakerphone. Our conditional app is a video app, so we'll set up the speakerphone right away:

// Moving AudioManager to the "call" state
audioManager.mode = AudioSystem.MODE_IN_COMMUNICATION
// Enabling speakerphone
audioManager.isSpeakerphoneOn = true

Great, we have applied the default settings. If the application design provides a button to toggle the speakerphone, we can now very easily implement its handling:

audioManager.isSpeakerphoneOn = !audioManager.isSpeakerphoneOn

Monitoring the connection of headphones

We've learned how to implement hands-free switching, but what happens if you connect headphones? Nothing, because audioManager.isSpeakerphoneOn is still true! And the user, of course, expects that when headphones are plugged in, the sound will start playing through them. And vice versa -- if we have a video call, then when we disconnect the headphones the sound should start playing through the speakerphone.

There is no way out, we have to monitor the connection of the headphones. Let me tell you right away, the connection of wired and Bluetooth headphones is tracked differently, so we have to implement two mechanisms at once. Let's start with wired ones and put the logic in a separate class:

class HeadsetStateProvider(
    private val context: Context,
    private val audioManager: AudioManager
) {
    // The current state of wired headies; true means enabled
    val isHeadsetPlugged = MutableStateFlow(getHeadsetState())

    // Create BroadcastReceiver to track the headset connection and disconnection events
    private val receiver = object : BroadcastReceiver() {
        override fun onReceive(context: Context?, intent: Intent) {
            if (intent.action == AudioManager.ACTION_HEADSET_PLUG) {
                when (intent.getIntExtra("state", -1)) {
                    // 0 -- the headset is offline, 1 -- the headset is online
                    0 -> isHeadsetPlugged.value = false
                    1 -> isHeadsetPlugged.value = true
                }
            }
        }
    }

    init {
        val filter = IntentFilter(Intent.ACTION_HEADSET_PLUG)
        // Register our BroadcastReceiver
        context.registerReceiver(receiver, filter)
    }

    // The method to receive a current headset state. It's used to initialize the starting point.
    fun getHeadsetState(): Boolean {
        val audioDevices = audioManager.getDevices(AudioManager.GET_DEVICES_OUTPUTS)
        return audioDevices.any {
            it.type == AudioDeviceInfo.TYPE_WIRED_HEADPHONES
                    || it.type == AudioDeviceInfo.TYPE_WIRED_HEADSET
        }
    }
}

In our example, we use StateFlow to implement subscription to the connection state, but instead, we can implement, for example, HeadsetStateProviderListener

Now just initialize this class and observe the isHeadsetPlugged field, turning the speaker on or off when it changes:

headsetStateProvider.isHeadsetPlugged
    // If the headset isn't on, speakerphone is.
    .onEach { audioManager.isSpeakerphoneOn = !it }
    .launchIn(someCoroutineScope)

Bluetooth headphones connection monitoring

Now we implement the same monitoring mechanism for such Android sound output devices as Bluetooth headphones:

class BluetoothHeadsetStateProvider(
    private val context: Context,
 private val bluetoothManager: BluetoothManager
) {

    val isHeadsetConnected = MutableStateFlow(getHeadsetState())

    init {
        // Receive the adapter from BluetoothManager and install our ServiceListener
        bluetoothManager.adapter.getProfileProxy(context, object : BluetoothProfile.ServiceListener {
            // This method will be used when the new device connects
            override fun onServiceConnected(profile: Int, proxy: BluetoothProfile?) {
                // Checking if it is the headset that's active
                if (profile == BluetoothProfile.HEADSET)
                    // Refreshing state
                    isHeadsetConnected.value = true
            }

            // This method will be used when the new device disconnects
            override fun onServiceDisconnected(profile: Int) 
                if (profile == BluetoothProfile.HEADSET)
                    isHeadsetConnected.value = false
            }
        // Enabling ServiceListener for headsets
        }, BluetoothProfile.HEADSET)
    }

    // The method of receiving the current state of the bluetooth headset. Only used to initialize the starting state
    private fun getHeadsetState(): Boolean {
        val adapter = bluetoothManager.adapter
        // Checking if there are active headsets  
        return adapter?.getProfileConnectionState(BluetoothProfile.HEADSET) == BluetoothProfile.STATE_CONNECTED
    }

}

To work with Bluetooth, we need another resolution:

<uses-permission android:name="android.permission.BLUETOOTH" />

And now to automatically turn on the speakerphone when no headset is connected, and vice versa when a new headset is connected:

combine(headsetStateProvider.isHeadsetPlugged, bluetoothHeadsetStateProvider.isHeadsetPlugged) { connected, bluetoothConnected ->
    audioManager.isSpeakerphoneOn = !connected && !bluetoothConnected
}
    .launchIn(someCoroutineScope)

Tidying up after ourselves

When the call is over, the audio focus is no longer useful to us and we have to get rid of it. Let's restore the settings we saved at the beginning:

audioManager.mode = savedAudioMode
audioManager.isMicrophoneMute = savedIsMicrophoneMuted
audioManager.isSpeakerphoneOn = savedIsSpeakerOn

And now, actually, let's give away the focus. Again, the implementation depends on the system version:

if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
    audioManager.abandonAudioFocusRequest(getAudioFocusRequest())
} else {
    // Listener для простоты опять оставим пустым
    audioManager.abandonAudioFocus { }
}

Bottom line

Great, here we have implemented the perfect UX of switching between Android sound output devices in our app. The main advantage of this approach is that it is almost independent of the specific implementation of calls: in any case, the played audio will be controlled by `AudioManager', and we control exactly at its level!