Плавное и своевременное переключение между устройствами аудио вывода — это функция, наличие которой обычно воспринимается как данность, но отсутствие (или проблемы с её работой) — сильно раздражает. Сегодня мы разберём, как реализовать такое переключение в приложениях для звонков под Android, начиная с "ручного" переключения пользователем и заканчивая автоматическим переключением при подключении гарнитур. Заодно затронем тему постановки на паузу всего остального аудио системы на время звонка. Рассматриваемая реализация подходит для практически всех приложений со звонками, так как действует на уровне системы, а не на уровне механизма звонков (например, WebRTC).

Управление устройствами аудио вывода

Все управление устройствами вывода звука в Android реализована через системный `AudioManager`. Для работы с ним необходимо добавить разрешение в `AndroidManifest.xml`:

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

В первую очередь, когда в нашем приложении начинается звонок, крайне рекомендуется захватить аудио фокус — сообщить системе, что пользователь сейчас с кем-то общается, и его лучше не отвлекать звуками из других приложений. Например, если пользователь слушал музыку, но ему позвонили и он ответил — музыка будет поставлена на паузу на время звонка.

Есть два механизма запроса аудио фокуса — старый уже deprecated, а новый доступен начиная с 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`, и мы управляем именно на его уровне!

  • Разработка