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!