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.modeval savedIsSpeakerOn = audioManager.isSpeakerphoneOnval 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)
                    // Обновляем состояние
                    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
    }
}
 
 

Now we implement the same monitoring mechanism for Bluetooth headphones:

To work with Bluetooth, we need another permission. For Android 12 and above, you need to declare in the manifest file and request at runtime following permission:


uses-permission android:name="android.permission.BLUETOOTH_CONNECT" /> 

For devices with Android 11 and below, you need to declare in the manifest:

 
 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 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 {
    // Let's leave it empty to keep it simple  
    audioManager.abandonAudioFocus { }
}
 

Limitations

In the app you can switch the sound output between three device types:

  • speaker
  • earpiece or wired
  • Bluetooth device

However you cannot switch between two Bluetooth devices. On Android 11 though, there’s now a feature to add the device switch to Notification. The switcher displays all available devices with the enabled volume control feature. So it will simply not show users the devices they can’t switch to from the one they’re currently using as an output.

To add the switcher, use the notif with the Notification.MediaStyle style with MediaSession connected to it:

 
 val mediaSession = MediaSession(this, MEDIA_SESSION_TAG)
val style = Notification.MediaStyle().setMediaSession(mediaSession.sessionToken)
val notification = Notification.Builder(this, CHANNEL_ID)
.setStyle(style)
.setSmallIcon(R.drawable.ic_launcher_foreground)
.build()
 

But how does Spotify have that quick and easy device switcher?

Our reader has noticed that Spotify does have that feature where you can switch between any devices you need. We cannot know for sure how they do that. But what we assume is that most likely Spotify implemented audio devices switching with MediaRouter API. It is used for seamless data exchange between two devices.

To learn more about the audio device switcher and MediaRouter, check out this article in Android Developer’s blog and the Media Routing documentation.

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!

  • Development