Как переключаться между устройствами вывода звука во время звонка в Android-приложении?

Плавное и своевременное переключение между устройствами вывода звука — это функция, которую обычно воспринимают как должное, но её отсутствие или сбои в работе сильно раздражают. Сегодня разберём, как реализовать такое переключение в приложениях для звонков под Android — от ручного выбора пользователем до автоматического переключения при подключении гарнитуры. Также поговорим о том, как при звонке приостанавливать воспроизведение всего остального звука в системе. Предложенная реализация подходит практически для всех приложений со звонками, потому что работает на уровне системы, а не на уровне конкретной технологии, например WebRTC.
Управление устройствами вывода звука
Все управление устройствами вывода звука в Android реализовано через системный `AudioManager`. Для работы с ним необходимо добавить разрешение в `AndroidManifest.xml`:
uses-permission android:name="android.permission.MODIFY_AUDIO_SETTINGS" /
В первую очередь, когда в нашем приложении начинается звонок, крайне рекомендуется захватить аудио фокус — сообщить системе, что пользователь сейчас разговаривает, и его лучше не отвлекать звуками из других приложений. Например, если пользователь слушал музыку, но ему позвонили и он ответил — музыка будет поставлена на паузу на время звонка.
Есть два механизма запроса аудиофокуса — старый уже устарел, а новый доступен начиная с Android 8.0. Мы реализуем поддержку для всех версий системы:
// Получаем экземпляр AudioManager
val audioManager = context.getSystemService(Context.AUDIO_SERVICE) as AudioManager
// Для нового подхода нам необходим "запрос". Сгенерируем его для версий >=8.0 и оставим null для более старых
@RequiresApi(Build.VERSION_CODES.O)
private fun getAudioFocusRequest() =
AudioFocusRequest.Builder(AudioManager.AUDIOFOCUS_GAIN).build()
// Запрос фокуса
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
// Используем сгененированный запрос
audioManager.requestAudioFocus(getAudioFocusRequest())
} else {
audioManager.requestAudioFocus(
// Listener получения фокуса. Для простоты примера оставим его пустым
{ },
// Указываем, что запрашиваем фокус для звонка
AudioAttributes.CONTENT_TYPE_SPEECH,
AudioManager.AUDIOFOCUS_GAIN
)
}
Важно указать подходящие значения `ContentType` и `Usage` — именно на их основе система решает, какую пользовательскую настройку громкости применять (громкость медиа или громкость звонка) и как поступать с другими аудиоисточниками: приглушать, ставить на паузу или оставлять без изменений.
Отлично, мы получили аудиофокус. Очень рекомендуется сразу сохранить исходные настройки AudioManager перед тем, как что-либо менять — это позволит восстановить его предыдущее состояние после окончания звонка. Представьте, как неудобно было бы, если бы переключатель громкой связи в одном приложении влиял на все остальные.
val savedAudioMode = audioManager.modeval savedIsSpeakerOn = audioManager.isSpeakerphoneOnval savedIsMicrophoneMuted = audioManager.isMicrophoneMute
Теперь мы можем настроить параметры по умолчанию. Они могут зависеть от типа звонка (обычно аудиозвонок идёт через «слуховой динамик», а видео — через громкую связь), от предпочтений пользователя в приложении или от того, какой динамик использовался в последний раз. Наше условное приложение — это приложение для видеосвязи, поэтому сразу включим громкую связь:
// Переводим AudioManager в состояние "звонка"
audioManager.mode = AudioSystem.MODE_IN_COMMUNICATION
// Включаем громкую связь
audioManager.isSpeakerphoneOn = true
Отлично, мы применили настройки по умолчанию. Если в дизайне приложения есть кнопка переключения громкой связи, теперь мы можем легко реализовать её обработку:
audioManager.isSpeakerphoneOn = !audioManager.isSpeakerphoneOn
Отслеживание подключения наушников
Мы научились включать и выключать громкую связь, но что будет, если подключить наушники? Ничего — `audioManager.isSpeakerphoneOn` всё ещё `true`! А пользователь, конечно, ожидает, что звук пойдёт через наушники. И наоборот: если идёт видеозвонок, то при отключении наушников звук должен автоматически переключиться на громкую связь.
Деваться некуда, нам придётся отслеживать подключение наушников. Скажу сразу — подключение проводных и Bluetooth-наушников отслеживается по-разному, поэтому придётся реализовать два механизма. Начнём с проводных — вынесем логику в отдельный класс:
class HeadsetStateProvider(
private val context: Context,
private val audioManager: AudioManager
) {
// Собственно текущее состояние проводных наушников; true -- подключены
val isHeadsetPlugged = MutableStateFlow(getHeadsetState())
// Создаём BroadcastReceiver для отслеживания событий подключения и отключения гарнитуры
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 -- гарнитура отключена, 1 -- подключена
0 -> isHeadsetPlugged.value = false
1 -> isHeadsetPlugged.value = true
}
}
}
}
init {
val filter = IntentFilter(Intent.ACTION_HEADSET_PLUG)
// Регистрируем наш BroadcastReceiver
context.registerReceiver(receiver, filter)
}
// Метод для получения текущего состояния гарнитуры -- используется только для инициализации начального состояния
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
}
}
}
В нашем примере мы используем `StateFlow` для отслеживания состояния подключения, но вместо этого можно применить, например, `HeadsetStateProviderListener`
Теперь достаточно создать экземпляр этого класса и следить за полем `isHeadsetPlugged`, включая или отключая громкую связь при его изменении:
headsetStateProvider.isHeadsetPlugged
// Если гарнитура НЕ подключена -- то громкая связь включена
.onEach { audioManager.isSpeakerphoneOn = !it }
.launchIn(someCoroutineScope)
Отслеживание подключения Bluetooth-наушников
Теперь реализуем аналогичный механизм отслеживания для Bluetooth-наушников:
class BluetoothHeadsetStateProvider(
private val context: Context,
private val bluetoothManager: BluetoothManager
) {
val isHeadsetConnected = MutableStateFlow(getHeadsetState())
init {
// Получаем от BluetoothManager адаптер (абстракцию над реальным bluetooth адаптером устройства) и устанавливаем наш ServiceListener
bluetoothManager.adapter.getProfileProxy(context, object : BluetoothProfile.ServiceListener {
// Этот метод будет вызван при подключении нового устройства
override fun onServiceConnected(profile: Int, proxy: BluetoothProfile?) {
// Проверяем, что была подключена именно гарнитура
if (profile == BluetoothProfile.HEADSET)
// Обновляем состояние
isHeadsetConnected.value = true
}
// Этот метод будет вызван при отключении нового устройства
override fun onServiceDisconnected(profile: Int)
if (profile == BluetoothProfile.HEADSET)
isHeadsetConnected.value = false
}
// Устанавливаем наш ServiceListener для именно гарнитур
}, BluetoothProfile.HEADSET)
}
// Метод для получения текущего состояния bluetooth гарнитуры, используется только для инициализации начального состояния
private fun getHeadsetState(): Boolean {
val adapter = bluetoothManager.adapter
// Проверяем, есть ли подключенные гарнитуры
return adapter?.getProfileConnectionState(BluetoothProfile.HEADSET) == BluetoothProfile.STATE_CONNECTED
}
}
Для работы с Bluetooth нам понадобится ещё одно разрешение:
uses-permission android:name="android.permission.BLUETOOTH" /
И теперь, чтобы автоматически включать громкую связь, когда гарнитура не подключена, и отключать её при подключении новой:
combine(headsetStateProvider.isHeadsetPlugged, bluetoothHeadsetStateProvider.isHeadsetPlugged) { connected, bluetoothConnected ->
audioManager.isSpeakerphoneOn = !connected && !bluetoothConnected
}
.launchIn(someCoroutineScope)
Прибираем за собой
Когда звонок завершён, аудиофокус нам больше не нужен, и его нужно освободить. Вернём настройки, которые сохранили в самом начале:
audioManager.mode = savedAudioMode
audioManager.isMicrophoneMute = savedIsMicrophoneMuted
audioManager.isSpeakerphoneOn = savedIsSpeakerOn
И теперь, собственно, отдадим фокус. Опять же, реализация зависит от версии системы:
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
audioManager.abandonAudioFocusRequest(getAudioFocusRequest())
} else {
// Listener для простоты опять оставим пустым
audioManager.abandonAudioFocus { }
}
Ограничения
В приложении можно переключаться между разными устройствами вывода:
- динамик
- наушники (проводные)
- Bluetooth-устройство (например, колонки или беспроводные наушники)
Однако переключиться между двумя Bluetooth-устройствами одновременно не получится. Но в Android 11 появилась возможность добавить переключение устройств в панель уведомлений. Там отображаются все доступные устройства, а для каждого можно регулировать громкость. В общем, это не просто список доступных каналов вывода, а полноценная функция.
Чтобы добавить такой переключатель, используем Notification.MediaStyle style с подключенным MediaSession:
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()
В Spotify можно переключаться между любыми устройствами. Как это реализовано?
Один из наших читателей заметил, что в Spotify нет никаких ограничений на переключение устройств. Скорее всего, они использовали MediaRouter API — его обычно применяют для бесшовной передачи медиа между устройствами.
Более подробную информацию о переключателе аудиоустройств и о MediaRouter можно найти в статье и в документации по Media Routing.
Итог
Отлично, теперь у нас реализован идеальный UX переключения между устройствами вывода аудио в приложении. Главное преимущество этого подхода — он почти не зависит от конкретной реализации звонков: в любом случае воспроизведение аудио будет контролироваться `AudioManager`, и мы работаем именно на его уровне!
