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

  • Разработка