davidliu
Committed by GitHub

handle audio output changes automatically in sample apps (#100)

1 <?xml version="1.0" encoding="utf-8"?> 1 <?xml version="1.0" encoding="utf-8"?>
2 -<manifest package="io.livekit.android.sample"> 2 +<manifest xmlns:android="http://schemas.android.com/apk/res/android"
  3 + package="io.livekit.android.sample">
3 4
  5 + <uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
  6 + <uses-permission android:name="android.permission.INTERNET" />
  7 + <uses-permission android:name="android.permission.MODIFY_AUDIO_SETTINGS" />
  8 + <uses-permission
  9 + android:name="android.permission.BLUETOOTH"
  10 + android:maxSdkVersion="30" />
  11 + <uses-permission
  12 + android:name="android.permission.BLUETOOTH_ADMIN"
  13 + android:maxSdkVersion="30" />
  14 + <uses-permission android:name="android.permission.BLUETOOTH_CONNECT" />
4 15
5 </manifest> 16 </manifest>
@@ -2,7 +2,10 @@ package io.livekit.android.sample @@ -2,7 +2,10 @@ package io.livekit.android.sample
2 2
3 import android.app.Application 3 import android.app.Application
4 import android.content.Intent 4 import android.content.Intent
5 -import androidx.lifecycle.* 5 +import androidx.lifecycle.AndroidViewModel
  6 +import androidx.lifecycle.LiveData
  7 +import androidx.lifecycle.MutableLiveData
  8 +import androidx.lifecycle.viewModelScope
6 import com.github.ajalt.timberkt.Timber 9 import com.github.ajalt.timberkt.Timber
7 import io.livekit.android.LiveKit 10 import io.livekit.android.LiveKit
8 import io.livekit.android.RoomOptions 11 import io.livekit.android.RoomOptions
@@ -13,6 +16,7 @@ import io.livekit.android.room.participant.LocalParticipant @@ -13,6 +16,7 @@ import io.livekit.android.room.participant.LocalParticipant
13 import io.livekit.android.room.participant.Participant 16 import io.livekit.android.room.participant.Participant
14 import io.livekit.android.room.participant.RemoteParticipant 17 import io.livekit.android.room.participant.RemoteParticipant
15 import io.livekit.android.room.track.* 18 import io.livekit.android.room.track.*
  19 +import io.livekit.android.sample.audio.AppRTCAudioManager
16 import io.livekit.android.util.flow 20 import io.livekit.android.util.flow
17 import kotlinx.coroutines.ExperimentalCoroutinesApi 21 import kotlinx.coroutines.ExperimentalCoroutinesApi
18 import kotlinx.coroutines.flow.* 22 import kotlinx.coroutines.flow.*
@@ -76,7 +80,12 @@ class CallViewModel( @@ -76,7 +80,12 @@ class CallViewModel(
76 private val mutablePermissionAllowed = MutableStateFlow(true) 80 private val mutablePermissionAllowed = MutableStateFlow(true)
77 val permissionAllowed = mutablePermissionAllowed.hide() 81 val permissionAllowed = mutablePermissionAllowed.hide()
78 82
  83 + private val audioManager = AppRTCAudioManager(application)
  84 +
79 init { 85 init {
  86 +
  87 + audioManager.start(null)
  88 +
80 viewModelScope.launch { 89 viewModelScope.launch {
81 90
82 launch { 91 launch {
@@ -199,6 +208,7 @@ class CallViewModel( @@ -199,6 +208,7 @@ class CallViewModel(
199 override fun onCleared() { 208 override fun onCleared() {
200 super.onCleared() 209 super.onCleared()
201 mutableRoom.value?.disconnect() 210 mutableRoom.value?.disconnect()
  211 + audioManager.stop()
202 } 212 }
203 213
204 fun setMicEnabled(enabled: Boolean) { 214 fun setMicEnabled(enabled: Boolean) {
  1 +/*
  2 + * Copyright 2014 The WebRTC Project Authors. All rights reserved.
  3 + *
  4 + * Use of this source code is governed by a BSD-style license
  5 + * that can be found in the LICENSE file in the root of the source
  6 + * tree. An additional intellectual property rights grant can be found
  7 + * in the file PATENTS. All contributing project authors may
  8 + * be found in the AUTHORS file in the root of the source tree.
  9 + */
  10 +@file:Suppress("unused")
  11 +
  12 +package io.livekit.android.sample.audio
  13 +
  14 +import android.annotation.SuppressLint
  15 +import android.content.BroadcastReceiver
  16 +import android.content.Context
  17 +import android.content.Intent
  18 +import android.content.IntentFilter
  19 +import android.content.pm.PackageManager
  20 +import android.media.AudioDeviceInfo
  21 +import android.media.AudioManager
  22 +import android.os.Build
  23 +import io.livekit.android.sample.audio.AppRTCBluetoothManager.Companion.create
  24 +import io.livekit.android.sample.audio.AppRTCProximitySensor.Companion.create
  25 +import org.webrtc.ThreadUtils
  26 +import timber.log.Timber
  27 +import java.util.*
  28 +
  29 +/**
  30 + * AppRTCAudioManager manages all audio related parts of the AppRTC demo.
  31 + */
  32 +@SuppressLint("BinaryOperationInTimber")
  33 +class AppRTCAudioManager
  34 +constructor(context: Context) {
  35 + /**
  36 + * AudioDevice is the names of possible audio devices that we currently
  37 + * support.
  38 + */
  39 + enum class AudioDevice {
  40 + SPEAKER_PHONE, WIRED_HEADSET, EARPIECE, BLUETOOTH, NONE
  41 + }
  42 +
  43 + /** AudioManager state. */
  44 + enum class AudioManagerState {
  45 + UNINITIALIZED, PREINITIALIZED, RUNNING
  46 + }
  47 +
  48 + /** Selected audio device change event. */
  49 + interface AudioManagerEvents {
  50 + // Callback fired once audio device is changed or list of available audio devices changed.
  51 + fun onAudioDeviceChanged(
  52 + selectedAudioDevice: AudioDevice?, availableAudioDevices: Set<AudioDevice>?
  53 + )
  54 + }
  55 +
  56 + private val apprtcContext: Context
  57 + private val audioManager: AudioManager
  58 + private var audioManagerEvents: AudioManagerEvents? = null
  59 + private var amState: AudioManagerState
  60 + private var savedAudioMode = AudioManager.MODE_NORMAL
  61 + private var savedIsSpeakerPhoneOn = false
  62 + private var savedIsMicrophoneMute = false
  63 + private var hasWiredHeadset = false
  64 +
  65 + // Default audio device; speaker phone for video calls or earpiece for audio
  66 + // only calls.
  67 + private var defaultAudioDevice: AudioDevice
  68 +
  69 + // Contains the currently selected audio device.
  70 + // This device is changed automatically using a certain scheme where e.g.
  71 + // a wired headset "wins" over speaker phone. It is also possible for a
  72 + // user to explicitly select a device (and overrid any predefined scheme).
  73 + // See |userSelectedAudioDevice| for details.
  74 + private var selectedAudioDevice: AudioDevice? = null
  75 +
  76 + // Contains the user-selected audio device which overrides the predefined
  77 + // selection scheme.
  78 + // TODO(henrika): always set to AudioDevice.NONE today. Add support for
  79 + // explicit selection based on choice by userSelectedAudioDevice.
  80 + private var userSelectedAudioDevice: AudioDevice? = null
  81 +
  82 + // Contains speakerphone setting: auto, true or false
  83 + private val useSpeakerphone: String?
  84 +
  85 + // Proximity sensor object. It measures the proximity of an object in cm
  86 + // relative to the view screen of a device and can therefore be used to
  87 + // assist device switching (close to ear <=> use headset earpiece if
  88 + // available, far from ear <=> use speaker phone).
  89 + private var proximitySensor: AppRTCProximitySensor?
  90 +
  91 + // Handles all tasks related to Bluetooth headset devices.
  92 + private val bluetoothManager: AppRTCBluetoothManager
  93 +
  94 + // Contains a list of available audio devices. A Set collection is used to
  95 + // avoid duplicate elements.
  96 + private var audioDevices: MutableSet<AudioDevice> = HashSet()
  97 +
  98 + // Broadcast receiver for wired headset intent broadcasts.
  99 + private val wiredHeadsetReceiver: BroadcastReceiver
  100 +
  101 + // Callback method for changes in audio focus.
  102 + private var audioFocusChangeListener: AudioManager.OnAudioFocusChangeListener? = null
  103 +
  104 + /**
  105 + * This method is called when the proximity sensor reports a state change,
  106 + * e.g. from "NEAR to FAR" or from "FAR to NEAR".
  107 + */
  108 + private fun onProximitySensorChangedState() {
  109 + if (useSpeakerphone != SPEAKERPHONE_AUTO) {
  110 + return
  111 + }
  112 +
  113 + // The proximity sensor should only be activated when there are exactly two
  114 + // available audio devices.
  115 + if (audioDevices.size == 2 && audioDevices.contains(AudioDevice.EARPIECE)
  116 + && audioDevices.contains(AudioDevice.SPEAKER_PHONE)
  117 + ) {
  118 + if (proximitySensor!!.sensorReportsNearState()) {
  119 + // Sensor reports that a "handset is being held up to a person's ear",
  120 + // or "something is covering the light sensor".
  121 + setAudioDeviceInternal(AudioDevice.EARPIECE)
  122 + } else {
  123 + // Sensor reports that a "handset is removed from a person's ear", or
  124 + // "the light sensor is no longer covered".
  125 + setAudioDeviceInternal(AudioDevice.SPEAKER_PHONE)
  126 + }
  127 + }
  128 + }
  129 +
  130 + /* Receiver which handles changes in wired headset availability. */
  131 + private inner class WiredHeadsetReceiver : BroadcastReceiver() {
  132 + override fun onReceive(context: Context, intent: Intent) {
  133 + val state = intent.getIntExtra("state", STATE_UNPLUGGED)
  134 + val microphone = intent.getIntExtra("microphone", HAS_NO_MIC)
  135 + val name = intent.getStringExtra("name")
  136 + Timber.d(
  137 + "WiredHeadsetReceiver.onReceive" + AppRTCUtils.threadInfo + ": "
  138 + + "a=" + intent.action + ", s="
  139 + + (if (state == STATE_UNPLUGGED) "unplugged" else "plugged") + ", m="
  140 + + (if (microphone == HAS_MIC) "mic" else "no mic") + ", n=" + name + ", sb="
  141 + + isInitialStickyBroadcast
  142 + )
  143 + hasWiredHeadset = state == STATE_PLUGGED
  144 + updateAudioDeviceState()
  145 + }
  146 +
  147 + }
  148 +
  149 + // TODO(henrika): audioManager.requestAudioFocus() is deprecated.
  150 + fun start(audioManagerEvents: AudioManagerEvents?) {
  151 + Timber.d("start")
  152 + ThreadUtils.checkIsOnMainThread()
  153 + if (amState == AudioManagerState.RUNNING) {
  154 + Timber.e("AudioManager is already active")
  155 + return
  156 + }
  157 + // TODO(henrika): perhaps call new method called preInitAudio() here if UNINITIALIZED.
  158 + Timber.d("AudioManager starts...")
  159 + this.audioManagerEvents = audioManagerEvents
  160 + amState = AudioManagerState.RUNNING
  161 +
  162 + // Store current audio state so we can restore it when stop() is called.
  163 + savedAudioMode = audioManager.mode
  164 + savedIsSpeakerPhoneOn = audioManager.isSpeakerphoneOn
  165 + savedIsMicrophoneMute = audioManager.isMicrophoneMute
  166 + hasWiredHeadset = hasWiredHeadset()
  167 +
  168 + // Create an AudioManager.OnAudioFocusChangeListener instance.
  169 + audioFocusChangeListener = AudioManager.OnAudioFocusChangeListener { focusChange ->
  170 +
  171 + // Called on the listener to notify if the audio focus for this listener has been changed.
  172 + // The |focusChange| value indicates whether the focus was gained, whether the focus was lost,
  173 + // and whether that loss is transient, or whether the new focus holder will hold it for an
  174 + // unknown amount of time.
  175 + // TODO(henrika): possibly extend support of handling audio-focus changes. Only contains
  176 + // logging for now.
  177 + val typeOfChange = when (focusChange) {
  178 + AudioManager.AUDIOFOCUS_GAIN -> "AUDIOFOCUS_GAIN"
  179 + AudioManager.AUDIOFOCUS_GAIN_TRANSIENT -> "AUDIOFOCUS_GAIN_TRANSIENT"
  180 + AudioManager.AUDIOFOCUS_GAIN_TRANSIENT_EXCLUSIVE -> "AUDIOFOCUS_GAIN_TRANSIENT_EXCLUSIVE"
  181 + AudioManager.AUDIOFOCUS_GAIN_TRANSIENT_MAY_DUCK -> "AUDIOFOCUS_GAIN_TRANSIENT_MAY_DUCK"
  182 + AudioManager.AUDIOFOCUS_LOSS -> "AUDIOFOCUS_LOSS"
  183 + AudioManager.AUDIOFOCUS_LOSS_TRANSIENT -> "AUDIOFOCUS_LOSS_TRANSIENT"
  184 + AudioManager.AUDIOFOCUS_LOSS_TRANSIENT_CAN_DUCK -> "AUDIOFOCUS_LOSS_TRANSIENT_CAN_DUCK"
  185 + else -> "AUDIOFOCUS_INVALID"
  186 + }
  187 + Timber.d("onAudioFocusChange: $typeOfChange")
  188 + }
  189 +
  190 + // Request audio playout focus (without ducking) and install listener for changes in focus.
  191 + val result = audioManager.requestAudioFocus(
  192 + audioFocusChangeListener,
  193 + AudioManager.STREAM_VOICE_CALL,
  194 + AudioManager.AUDIOFOCUS_GAIN,
  195 + )
  196 + if (result == AudioManager.AUDIOFOCUS_REQUEST_GRANTED) {
  197 + Timber.d("Audio focus request granted for VOICE_CALL streams")
  198 + } else {
  199 + Timber.e("Audio focus request failed")
  200 + }
  201 +
  202 + // Start by setting MODE_IN_COMMUNICATION as default audio mode. It is
  203 + // required to be in this mode when playout and/or recording starts for
  204 + // best possible VoIP performance.
  205 + audioManager.mode = AudioManager.MODE_IN_COMMUNICATION
  206 +
  207 + // Always disable microphone mute during a WebRTC call.
  208 + setMicrophoneMute(false)
  209 +
  210 + // Set initial device states.
  211 + userSelectedAudioDevice = AudioDevice.NONE
  212 + selectedAudioDevice = AudioDevice.NONE
  213 + audioDevices.clear()
  214 +
  215 + // Initialize and start Bluetooth if a BT device is available or initiate
  216 + // detection of new (enabled) BT devices.
  217 + bluetoothManager.start()
  218 +
  219 + // Do initial selection of audio device. This setting can later be changed
  220 + // either by adding/removing a BT or wired headset or by covering/uncovering
  221 + // the proximity sensor.
  222 + updateAudioDeviceState()
  223 +
  224 + // Register receiver for broadcast intents related to adding/removing a
  225 + // wired headset.
  226 + registerReceiver(wiredHeadsetReceiver, IntentFilter(Intent.ACTION_HEADSET_PLUG))
  227 + Timber.d("AudioManager started")
  228 + }
  229 +
  230 + // TODO(henrika): audioManager.abandonAudioFocus() is deprecated.
  231 + fun stop() {
  232 + Timber.d("stop")
  233 + ThreadUtils.checkIsOnMainThread()
  234 + if (amState != AudioManagerState.RUNNING) {
  235 + Timber.e("Trying to stop AudioManager in incorrect state: $amState")
  236 + return
  237 + }
  238 + amState = AudioManagerState.UNINITIALIZED
  239 + unregisterReceiver(wiredHeadsetReceiver)
  240 + bluetoothManager.stop()
  241 +
  242 + // Restore previously stored audio states.
  243 + setSpeakerphoneOn(savedIsSpeakerPhoneOn)
  244 + setMicrophoneMute(savedIsMicrophoneMute)
  245 + audioManager.mode = savedAudioMode
  246 +
  247 + // Abandon audio focus. Gives the previous focus owner, if any, focus.
  248 + audioManager.abandonAudioFocus(audioFocusChangeListener)
  249 + audioFocusChangeListener = null
  250 + Timber.d("Abandoned audio focus for VOICE_CALL streams")
  251 + if (proximitySensor != null) {
  252 + proximitySensor!!.stop()
  253 + proximitySensor = null
  254 + }
  255 + audioManagerEvents = null
  256 + Timber.d("AudioManager stopped")
  257 + }
  258 +
  259 + /** Changes selection of the currently active audio device. */
  260 + private fun setAudioDeviceInternal(device: AudioDevice) {
  261 + Timber.d("setAudioDeviceInternal(device=$device)")
  262 + AppRTCUtils.assertIsTrue(audioDevices.contains(device))
  263 + when (device) {
  264 + AudioDevice.SPEAKER_PHONE -> setSpeakerphoneOn(true)
  265 + AudioDevice.EARPIECE -> setSpeakerphoneOn(false)
  266 + AudioDevice.WIRED_HEADSET -> setSpeakerphoneOn(false)
  267 + AudioDevice.BLUETOOTH -> setSpeakerphoneOn(false)
  268 + else -> Timber.e("Invalid audio device selection")
  269 + }
  270 + selectedAudioDevice = device
  271 + }
  272 +
  273 + /**
  274 + * Changes default audio device.
  275 + * TODO(henrika): add usage of this method in the AppRTCMobile client.
  276 + */
  277 + fun setDefaultAudioDevice(defaultDevice: AudioDevice?) {
  278 + ThreadUtils.checkIsOnMainThread()
  279 + when (defaultDevice) {
  280 + AudioDevice.SPEAKER_PHONE -> defaultAudioDevice = defaultDevice
  281 + AudioDevice.EARPIECE -> defaultAudioDevice = if (hasEarpiece()) {
  282 + defaultDevice
  283 + } else {
  284 + AudioDevice.SPEAKER_PHONE
  285 + }
  286 + else -> Timber.e("Invalid default audio device selection")
  287 + }
  288 + Timber.d("setDefaultAudioDevice(device=$defaultAudioDevice)")
  289 + updateAudioDeviceState()
  290 + }
  291 +
  292 + /** Changes selection of the currently active audio device. */
  293 + fun selectAudioDevice(device: AudioDevice) {
  294 + ThreadUtils.checkIsOnMainThread()
  295 + if (!audioDevices.contains(device)) {
  296 + Timber.e("Can not select $device from available $audioDevices")
  297 + }
  298 + userSelectedAudioDevice = device
  299 + updateAudioDeviceState()
  300 + }
  301 +
  302 + /** Returns current set of available/selectable audio devices. */
  303 + fun getAudioDevices(): Set<AudioDevice> {
  304 + ThreadUtils.checkIsOnMainThread()
  305 + return Collections.unmodifiableSet(HashSet(audioDevices))
  306 + }
  307 +
  308 + /** Returns the currently selected audio device. */
  309 + fun getSelectedAudioDevice(): AudioDevice? {
  310 + ThreadUtils.checkIsOnMainThread()
  311 + return selectedAudioDevice
  312 + }
  313 +
  314 + /** Helper method for receiver registration. */
  315 + private fun registerReceiver(receiver: BroadcastReceiver, filter: IntentFilter) {
  316 + apprtcContext.registerReceiver(receiver, filter)
  317 + }
  318 +
  319 + /** Helper method for unregistration of an existing receiver. */
  320 + private fun unregisterReceiver(receiver: BroadcastReceiver) {
  321 + apprtcContext.unregisterReceiver(receiver)
  322 + }
  323 +
  324 + /** Sets the speaker phone mode. */
  325 + private fun setSpeakerphoneOn(on: Boolean) {
  326 + val wasOn = audioManager.isSpeakerphoneOn
  327 + if (wasOn == on) {
  328 + return
  329 + }
  330 + audioManager.isSpeakerphoneOn = on
  331 + }
  332 +
  333 + /** Sets the microphone mute state. */
  334 + private fun setMicrophoneMute(on: Boolean) {
  335 + val wasMuted = audioManager.isMicrophoneMute
  336 + if (wasMuted == on) {
  337 + return
  338 + }
  339 + audioManager.isMicrophoneMute = on
  340 + }
  341 +
  342 + /** Gets the current earpiece state. */
  343 + private fun hasEarpiece(): Boolean {
  344 + return apprtcContext.packageManager.hasSystemFeature(PackageManager.FEATURE_TELEPHONY)
  345 + }
  346 +
  347 + /**
  348 + * Checks whether a wired headset is connected or not.
  349 + * This is not a valid indication that audio playback is actually over
  350 + * the wired headset as audio routing depends on other conditions. We
  351 + * only use it as an early indicator (during initialization) of an attached
  352 + * wired headset.
  353 + */
  354 + private fun hasWiredHeadset(): Boolean {
  355 + return if (Build.VERSION.SDK_INT < Build.VERSION_CODES.M) {
  356 + @Suppress("DEPRECATION")
  357 + audioManager.isWiredHeadsetOn
  358 + } else {
  359 + val devices = audioManager.getDevices(AudioManager.GET_DEVICES_OUTPUTS or AudioManager.GET_DEVICES_INPUTS)
  360 + for (device in devices) {
  361 + val type = device.type
  362 + if (type == AudioDeviceInfo.TYPE_WIRED_HEADSET) {
  363 + Timber.d("hasWiredHeadset: found wired headset")
  364 + return true
  365 + } else if (type == AudioDeviceInfo.TYPE_USB_DEVICE) {
  366 + Timber.d("hasWiredHeadset: found USB audio device")
  367 + return true
  368 + }
  369 + }
  370 + false
  371 + }
  372 + }
  373 +
  374 + /**
  375 + * Updates list of possible audio devices and make new device selection.
  376 + * TODO(henrika): add unit test to verify all state transitions.
  377 + */
  378 + fun updateAudioDeviceState() {
  379 + ThreadUtils.checkIsOnMainThread()
  380 + Timber.d(
  381 + "--- updateAudioDeviceState: "
  382 + + "wired headset=" + hasWiredHeadset + ", "
  383 + + "BT state=" + bluetoothManager.state
  384 + )
  385 + Timber.d(
  386 + "Device status: "
  387 + + "available=" + audioDevices + ", "
  388 + + "selected=" + selectedAudioDevice + ", "
  389 + + "user selected=" + userSelectedAudioDevice
  390 + )
  391 +
  392 + // Check if any Bluetooth headset is connected. The internal BT state will
  393 + // change accordingly.
  394 + // TODO(henrika): perhaps wrap required state into BT manager.
  395 + if (bluetoothManager.state === AppRTCBluetoothManager.State.HEADSET_AVAILABLE || bluetoothManager.state === AppRTCBluetoothManager.State.HEADSET_UNAVAILABLE || bluetoothManager.state === AppRTCBluetoothManager.State.SCO_DISCONNECTING) {
  396 + bluetoothManager.updateDevice()
  397 + }
  398 +
  399 + // Update the set of available audio devices.
  400 + val newAudioDevices: MutableSet<AudioDevice> = HashSet()
  401 + if (bluetoothManager.state === AppRTCBluetoothManager.State.SCO_CONNECTED || bluetoothManager.state === AppRTCBluetoothManager.State.SCO_CONNECTING || bluetoothManager.state === AppRTCBluetoothManager.State.HEADSET_AVAILABLE) {
  402 + newAudioDevices.add(AudioDevice.BLUETOOTH)
  403 + }
  404 + if (hasWiredHeadset) {
  405 + // If a wired headset is connected, then it is the only possible option.
  406 + newAudioDevices.add(AudioDevice.WIRED_HEADSET)
  407 + } else {
  408 + // No wired headset, hence the audio-device list can contain speaker
  409 + // phone (on a tablet), or speaker phone and earpiece (on mobile phone).
  410 + newAudioDevices.add(AudioDevice.SPEAKER_PHONE)
  411 + if (hasEarpiece()) {
  412 + newAudioDevices.add(AudioDevice.EARPIECE)
  413 + }
  414 + }
  415 + // Store state which is set to true if the device list has changed.
  416 + var audioDeviceSetUpdated = audioDevices != newAudioDevices
  417 + // Update the existing audio device set.
  418 + audioDevices = newAudioDevices
  419 + // Correct user selected audio devices if needed.
  420 + if (bluetoothManager.state === AppRTCBluetoothManager.State.HEADSET_UNAVAILABLE
  421 + && userSelectedAudioDevice == AudioDevice.BLUETOOTH
  422 + ) {
  423 + // If BT is not available, it can't be the user selection.
  424 + userSelectedAudioDevice = AudioDevice.NONE
  425 + }
  426 + if (hasWiredHeadset && userSelectedAudioDevice == AudioDevice.SPEAKER_PHONE) {
  427 + // If user selected speaker phone, but then plugged wired headset then make
  428 + // wired headset as user selected device.
  429 + userSelectedAudioDevice = AudioDevice.WIRED_HEADSET
  430 + }
  431 + if (!hasWiredHeadset && userSelectedAudioDevice == AudioDevice.WIRED_HEADSET) {
  432 + // If user selected wired headset, but then unplugged wired headset then make
  433 + // speaker phone as user selected device.
  434 + userSelectedAudioDevice = AudioDevice.SPEAKER_PHONE
  435 + }
  436 +
  437 + // Need to start Bluetooth if it is available and user either selected it explicitly or
  438 + // user did not select any output device.
  439 + val needBluetoothAudioStart = (bluetoothManager.state === AppRTCBluetoothManager.State.HEADSET_AVAILABLE
  440 + && (userSelectedAudioDevice == AudioDevice.NONE
  441 + || userSelectedAudioDevice == AudioDevice.BLUETOOTH))
  442 +
  443 + // Need to stop Bluetooth audio if user selected different device and
  444 + // Bluetooth SCO connection is established or in the process.
  445 + val needBluetoothAudioStop = ((bluetoothManager.state === AppRTCBluetoothManager.State.SCO_CONNECTED
  446 + || bluetoothManager.state === AppRTCBluetoothManager.State.SCO_CONNECTING)
  447 + && (userSelectedAudioDevice != AudioDevice.NONE
  448 + && userSelectedAudioDevice != AudioDevice.BLUETOOTH))
  449 + if (bluetoothManager.state === AppRTCBluetoothManager.State.HEADSET_AVAILABLE || bluetoothManager.state === AppRTCBluetoothManager.State.SCO_CONNECTING || bluetoothManager.state === AppRTCBluetoothManager.State.SCO_CONNECTED) {
  450 + Timber.d(
  451 + "Need BT audio: start=" + needBluetoothAudioStart + ", "
  452 + + "stop=" + needBluetoothAudioStop + ", "
  453 + + "BT state=" + bluetoothManager.state
  454 + )
  455 + }
  456 +
  457 + // Start or stop Bluetooth SCO connection given states set earlier.
  458 + if (needBluetoothAudioStop) {
  459 + bluetoothManager.stopScoAudio()
  460 + bluetoothManager.updateDevice()
  461 + }
  462 + if (needBluetoothAudioStart && !needBluetoothAudioStop) {
  463 + // Attempt to start Bluetooth SCO audio (takes a few second to start).
  464 + if (!bluetoothManager.startScoAudio()) {
  465 + // Remove BLUETOOTH from list of available devices since SCO failed.
  466 + audioDevices.remove(AudioDevice.BLUETOOTH)
  467 + audioDeviceSetUpdated = true
  468 + }
  469 + }
  470 +
  471 + // Update selected audio device.
  472 + val newAudioDevice = if (bluetoothManager.state === AppRTCBluetoothManager.State.SCO_CONNECTED) {
  473 + // If a Bluetooth is connected, then it should be used as output audio
  474 + // device. Note that it is not sufficient that a headset is available;
  475 + // an active SCO channel must also be up and running.
  476 + AudioDevice.BLUETOOTH
  477 + } else if (hasWiredHeadset) {
  478 + // If a wired headset is connected, but Bluetooth is not, then wired headset is used as
  479 + // audio device.
  480 + AudioDevice.WIRED_HEADSET
  481 + } else {
  482 + // No wired headset and no Bluetooth, hence the audio-device list can contain speaker
  483 + // phone (on a tablet), or speaker phone and earpiece (on mobile phone).
  484 + // |defaultAudioDevice| contains either AudioDevice.SPEAKER_PHONE or AudioDevice.EARPIECE
  485 + // depending on the user's selection.
  486 + defaultAudioDevice
  487 + }
  488 + // Switch to new device but only if there has been any changes.
  489 + if (newAudioDevice != selectedAudioDevice || audioDeviceSetUpdated) {
  490 + // Do the required device switch.
  491 + setAudioDeviceInternal(newAudioDevice)
  492 + Timber.d(
  493 + "New device status: "
  494 + + "available=" + audioDevices + ", "
  495 + + "selected=" + newAudioDevice
  496 + )
  497 + if (audioManagerEvents != null) {
  498 + // Notify a listening client that audio device has been changed.
  499 + audioManagerEvents!!.onAudioDeviceChanged(selectedAudioDevice, audioDevices)
  500 + }
  501 + }
  502 + Timber.d("--- updateAudioDeviceState done")
  503 + }
  504 +
  505 + companion object {
  506 + private const val TAG = "AppRTCAudioManager"
  507 + private const val SPEAKERPHONE_AUTO = "auto"
  508 + private const val SPEAKERPHONE_TRUE = "true"
  509 + private const val SPEAKERPHONE_FALSE = "false"
  510 +
  511 + private const val STATE_UNPLUGGED = 0
  512 + private const val STATE_PLUGGED = 1
  513 + private const val HAS_NO_MIC = 0
  514 + private const val HAS_MIC = 1
  515 + }
  516 +
  517 + init {
  518 + Timber.d("ctor")
  519 + ThreadUtils.checkIsOnMainThread()
  520 + apprtcContext = context
  521 + audioManager = context.getSystemService(Context.AUDIO_SERVICE) as AudioManager
  522 + bluetoothManager = create(context, this)
  523 + wiredHeadsetReceiver = WiredHeadsetReceiver()
  524 + amState = AudioManagerState.UNINITIALIZED
  525 + useSpeakerphone = SPEAKERPHONE_TRUE
  526 + defaultAudioDevice = AudioDevice.SPEAKER_PHONE
  527 +
  528 + // Create and initialize the proximity sensor.
  529 + // Tablet devices (e.g. Nexus 7) does not support proximity sensors.
  530 + // Note that, the sensor will not be active until start() has been called.
  531 + proximitySensor = create(context) { onProximitySensorChangedState() }
  532 + Timber.d("defaultAudioDevice: $defaultAudioDevice")
  533 + AppRTCUtils.logDeviceInfo(TAG)
  534 + }
  535 +}
  1 +/*
  2 + * Copyright 2016 The WebRTC Project Authors. All rights reserved.
  3 + *
  4 + * Use of this source code is governed by a BSD-style license
  5 + * that can be found in the LICENSE file in the root of the source
  6 + * tree. An additional intellectual property rights grant can be found
  7 + * in the file PATENTS. All contributing project authors may
  8 + * be found in the AUTHORS file in the root of the source tree.
  9 + */
  10 +package io.livekit.android.sample.audio
  11 +
  12 +import android.Manifest
  13 +import android.annotation.SuppressLint
  14 +import android.bluetooth.BluetoothAdapter
  15 +import android.bluetooth.BluetoothDevice
  16 +import android.bluetooth.BluetoothHeadset
  17 +import android.bluetooth.BluetoothProfile
  18 +import android.content.BroadcastReceiver
  19 +import android.content.Context
  20 +import android.content.Intent
  21 +import android.content.IntentFilter
  22 +import android.content.pm.PackageManager
  23 +import android.media.AudioManager
  24 +import android.os.Build
  25 +import android.os.Handler
  26 +import android.os.Looper
  27 +import android.os.Process
  28 +import org.webrtc.ThreadUtils
  29 +import timber.log.Timber
  30 +
  31 +/**
  32 + * AppRTCProximitySensor manages functions related to Bluetoth devices in the
  33 + * AppRTC demo.
  34 + */
  35 +@Suppress("MemberVisibilityCanBePrivate")
  36 +@SuppressLint("BinaryOperationInTimber")
  37 +open class AppRTCBluetoothManager
  38 +constructor(context: Context, audioManager: AppRTCAudioManager) {
  39 + // Bluetooth connection state.
  40 + enum class State {
  41 + // Bluetooth is not available; no adapter or Bluetooth is off.
  42 + UNINITIALIZED, // Bluetooth error happened when trying to start Bluetooth.
  43 + ERROR, // Bluetooth proxy object for the Headset profile exists, but no connected headset devices,
  44 +
  45 + // SCO is not started or disconnected.
  46 + HEADSET_UNAVAILABLE, // Bluetooth proxy object for the Headset profile connected, connected Bluetooth headset
  47 +
  48 + // present, but SCO is not started or disconnected.
  49 + HEADSET_AVAILABLE, // Bluetooth audio SCO connection with remote device is closing.
  50 + SCO_DISCONNECTING, // Bluetooth audio SCO connection with remote device is initiated.
  51 + SCO_CONNECTING, // Bluetooth audio SCO connection with remote device is established.
  52 + SCO_CONNECTED
  53 + }
  54 +
  55 + private val apprtcContext: Context
  56 + private val apprtcAudioManager: AppRTCAudioManager
  57 + private val audioManager: AudioManager?
  58 + private val handler: Handler
  59 + var scoConnectionAttempts = 0
  60 + private var bluetoothState: State
  61 + private val bluetoothServiceListener: BluetoothProfile.ServiceListener
  62 + private var bluetoothAdapter: BluetoothAdapter? = null
  63 + private var bluetoothHeadset: BluetoothHeadset? = null
  64 + private var bluetoothDevice: BluetoothDevice? = null
  65 + private val bluetoothHeadsetReceiver: BroadcastReceiver
  66 +
  67 + // Runs when the Bluetooth timeout expires. We use that timeout after calling
  68 + // startScoAudio() or stopScoAudio() because we're not guaranteed to get a
  69 + // callback after those calls.
  70 + private val bluetoothTimeoutRunnable = Runnable { bluetoothTimeout() }
  71 +
  72 + /**
  73 + * Implementation of an interface that notifies BluetoothProfile IPC clients when they have been
  74 + * connected to or disconnected from the service.
  75 + */
  76 + private inner class BluetoothServiceListener : BluetoothProfile.ServiceListener {
  77 + // Called to notify the client when the proxy object has been connected to the service.
  78 + // Once we have the profile proxy object, we can use it to monitor the state of the
  79 + // connection and perform other operations that are relevant to the headset profile.
  80 + override fun onServiceConnected(profile: Int, proxy: BluetoothProfile) {
  81 + if (profile != BluetoothProfile.HEADSET || bluetoothState == State.UNINITIALIZED) {
  82 + return
  83 + }
  84 + Timber.d("BluetoothServiceListener.onServiceConnected: BT state=$bluetoothState")
  85 + // Android only supports one connected Bluetooth Headset at a time.
  86 + bluetoothHeadset = proxy as BluetoothHeadset
  87 + updateAudioDeviceState()
  88 + Timber.d("onServiceConnected done: BT state=$bluetoothState")
  89 + }
  90 +
  91 + /** Notifies the client when the proxy object has been disconnected from the service. */
  92 + override fun onServiceDisconnected(profile: Int) {
  93 + if (profile != BluetoothProfile.HEADSET || bluetoothState == State.UNINITIALIZED) {
  94 + return
  95 + }
  96 + Timber.d("BluetoothServiceListener.onServiceDisconnected: BT state=$bluetoothState")
  97 + stopScoAudio()
  98 + bluetoothHeadset = null
  99 + bluetoothDevice = null
  100 + bluetoothState = State.HEADSET_UNAVAILABLE
  101 + updateAudioDeviceState()
  102 + Timber.d("onServiceDisconnected done: BT state=$bluetoothState")
  103 + }
  104 + }
  105 +
  106 + // Intent broadcast receiver which handles changes in Bluetooth device availability.
  107 + // Detects headset changes and Bluetooth SCO state changes.
  108 + private inner class BluetoothHeadsetBroadcastReceiver : BroadcastReceiver() {
  109 + override fun onReceive(context: Context, intent: Intent) {
  110 + if (bluetoothState == State.UNINITIALIZED) {
  111 + return
  112 + }
  113 + val action = intent.action
  114 + // Change in connection state of the Headset profile. Note that the
  115 + // change does not tell us anything about whether we're streaming
  116 + // audio to BT over SCO. Typically received when user turns on a BT
  117 + // headset while audio is active using another audio device.
  118 + if (action == BluetoothHeadset.ACTION_CONNECTION_STATE_CHANGED) {
  119 + val state = intent.getIntExtra(BluetoothHeadset.EXTRA_STATE, BluetoothHeadset.STATE_DISCONNECTED)
  120 + Timber.d(
  121 + """BluetoothHeadsetBroadcastReceiver.onReceive: a=ACTION_CONNECTION_STATE_CHANGED, s=${
  122 + stateToString(
  123 + state
  124 + )
  125 + }, sb=$isInitialStickyBroadcast, BT state: $bluetoothState"""
  126 + )
  127 + if (state == BluetoothHeadset.STATE_CONNECTED) {
  128 + scoConnectionAttempts = 0
  129 + updateAudioDeviceState()
  130 + } else if (state == BluetoothHeadset.STATE_CONNECTING) {
  131 + // No action needed.
  132 + } else if (state == BluetoothHeadset.STATE_DISCONNECTING) {
  133 + // No action needed.
  134 + } else if (state == BluetoothHeadset.STATE_DISCONNECTED) {
  135 + // Bluetooth is probably powered off during the call.
  136 + stopScoAudio()
  137 + updateAudioDeviceState()
  138 + }
  139 + // Change in the audio (SCO) connection state of the Headset profile.
  140 + // Typically received after call to startScoAudio() has finalized.
  141 + } else if (action == BluetoothHeadset.ACTION_AUDIO_STATE_CHANGED) {
  142 + val state = intent.getIntExtra(
  143 + BluetoothHeadset.EXTRA_STATE, BluetoothHeadset.STATE_AUDIO_DISCONNECTED
  144 + )
  145 + Timber.d(
  146 + "BluetoothHeadsetBroadcastReceiver.onReceive: "
  147 + + "a=ACTION_AUDIO_STATE_CHANGED, "
  148 + + "s=" + stateToString(state) + ", "
  149 + + "sb=" + isInitialStickyBroadcast + ", "
  150 + + "BT state: " + bluetoothState
  151 + )
  152 + if (state == BluetoothHeadset.STATE_AUDIO_CONNECTED) {
  153 + cancelTimer()
  154 + if (bluetoothState == State.SCO_CONNECTING) {
  155 + Timber.d("+++ Bluetooth audio SCO is now connected")
  156 + bluetoothState = State.SCO_CONNECTED
  157 + scoConnectionAttempts = 0
  158 + updateAudioDeviceState()
  159 + } else {
  160 + Timber.w("Unexpected state BluetoothHeadset.STATE_AUDIO_CONNECTED")
  161 + }
  162 + } else if (state == BluetoothHeadset.STATE_AUDIO_CONNECTING) {
  163 + Timber.d("+++ Bluetooth audio SCO is now connecting...")
  164 + } else if (state == BluetoothHeadset.STATE_AUDIO_DISCONNECTED) {
  165 + Timber.d("+++ Bluetooth audio SCO is now disconnected")
  166 + if (isInitialStickyBroadcast) {
  167 + Timber.d("Ignore STATE_AUDIO_DISCONNECTED initial sticky broadcast.")
  168 + return
  169 + }
  170 + updateAudioDeviceState()
  171 + }
  172 + }
  173 + Timber.d("onReceive done: BT state=$bluetoothState")
  174 + }
  175 + }
  176 +
  177 + /** Returns the internal state. */
  178 + val state: State
  179 + get() {
  180 + ThreadUtils.checkIsOnMainThread()
  181 + return bluetoothState
  182 + }
  183 +
  184 + /**
  185 + * Activates components required to detect Bluetooth devices and to enable
  186 + * BT SCO (audio is routed via BT SCO) for the headset profile. The end
  187 + * state will be HEADSET_UNAVAILABLE but a state machine has started which
  188 + * will start a state change sequence where the final outcome depends on
  189 + * if/when the BT headset is enabled.
  190 + * Example of state change sequence when start() is called while BT device
  191 + * is connected and enabled:
  192 + * UNINITIALIZED --> HEADSET_UNAVAILABLE --> HEADSET_AVAILABLE -->
  193 + * SCO_CONNECTING --> SCO_CONNECTED <==> audio is now routed via BT SCO.
  194 + * Note that the AppRTCAudioManager is also involved in driving this state
  195 + * change.
  196 + */
  197 + fun start() {
  198 + ThreadUtils.checkIsOnMainThread()
  199 + Timber.d("start")
  200 + if (!hasPermission(apprtcContext, Manifest.permission.BLUETOOTH) &&
  201 + !(Build.VERSION.SDK_INT >= Build.VERSION_CODES.S &&
  202 + hasPermission(apprtcContext, Manifest.permission.BLUETOOTH_CONNECT))
  203 + ) {
  204 + Timber.w("Process (pid=" + Process.myPid() + ") lacks BLUETOOTH permission")
  205 + return
  206 + }
  207 + if (bluetoothState != State.UNINITIALIZED) {
  208 + Timber.w("Invalid BT state")
  209 + return
  210 + }
  211 + bluetoothHeadset = null
  212 + bluetoothDevice = null
  213 + scoConnectionAttempts = 0
  214 + // Get a handle to the default local Bluetooth adapter.
  215 + bluetoothAdapter = BluetoothAdapter.getDefaultAdapter()
  216 + if (bluetoothAdapter == null) {
  217 + Timber.w("Device does not support Bluetooth")
  218 + return
  219 + }
  220 + // Ensure that the device supports use of BT SCO audio for off call use cases.
  221 + if (!audioManager!!.isBluetoothScoAvailableOffCall) {
  222 + Timber.e("Bluetooth SCO audio is not available off call")
  223 + return
  224 + }
  225 + logBluetoothAdapterInfo(bluetoothAdapter!!)
  226 + // Establish a connection to the HEADSET profile (includes both Bluetooth Headset and
  227 + // Hands-Free) proxy object and install a listener.
  228 + if (!getBluetoothProfileProxy(
  229 + apprtcContext, bluetoothServiceListener, BluetoothProfile.HEADSET
  230 + )
  231 + ) {
  232 + Timber.e("BluetoothAdapter.getProfileProxy(HEADSET) failed")
  233 + return
  234 + }
  235 + // Register receivers for BluetoothHeadset change notifications.
  236 + val bluetoothHeadsetFilter = IntentFilter()
  237 + // Register receiver for change in connection state of the Headset profile.
  238 + bluetoothHeadsetFilter.addAction(BluetoothHeadset.ACTION_CONNECTION_STATE_CHANGED)
  239 + // Register receiver for change in audio connection state of the Headset profile.
  240 + bluetoothHeadsetFilter.addAction(BluetoothHeadset.ACTION_AUDIO_STATE_CHANGED)
  241 + registerReceiver(bluetoothHeadsetReceiver, bluetoothHeadsetFilter)
  242 + Timber.d(
  243 + "HEADSET profile state: "
  244 + + stateToString(bluetoothAdapter!!.getProfileConnectionState(BluetoothProfile.HEADSET))
  245 + )
  246 + Timber.d("Bluetooth proxy for headset profile has started")
  247 + bluetoothState = State.HEADSET_UNAVAILABLE
  248 + Timber.d("start done: BT state=$bluetoothState")
  249 + }
  250 +
  251 + /** Stops and closes all components related to Bluetooth audio. */
  252 + fun stop() {
  253 + ThreadUtils.checkIsOnMainThread()
  254 + Timber.d("stop: BT state=$bluetoothState")
  255 + if (bluetoothAdapter == null) {
  256 + return
  257 + }
  258 + // Stop BT SCO connection with remote device if needed.
  259 + stopScoAudio()
  260 + // Close down remaining BT resources.
  261 + if (bluetoothState == State.UNINITIALIZED) {
  262 + return
  263 + }
  264 + unregisterReceiver(bluetoothHeadsetReceiver)
  265 + cancelTimer()
  266 + if (bluetoothHeadset != null) {
  267 + bluetoothAdapter!!.closeProfileProxy(BluetoothProfile.HEADSET, bluetoothHeadset)
  268 + bluetoothHeadset = null
  269 + }
  270 + bluetoothAdapter = null
  271 + bluetoothDevice = null
  272 + bluetoothState = State.UNINITIALIZED
  273 + Timber.d("stop done: BT state=$bluetoothState")
  274 + }
  275 +
  276 + /**
  277 + * Starts Bluetooth SCO connection with remote device.
  278 + * Note that the phone application always has the priority on the usage of the SCO connection
  279 + * for telephony. If this method is called while the phone is in call it will be ignored.
  280 + * Similarly, if a call is received or sent while an application is using the SCO connection,
  281 + * the connection will be lost for the application and NOT returned automatically when the call
  282 + * ends. Also note that: up to and including API version JELLY_BEAN_MR1, this method initiates a
  283 + * virtual voice call to the Bluetooth headset. After API version JELLY_BEAN_MR2 only a raw SCO
  284 + * audio connection is established.
  285 + * TODO(henrika): should we add support for virtual voice call to BT headset also for JBMR2 and
  286 + * higher. It might be required to initiates a virtual voice call since many devices do not
  287 + * accept SCO audio without a "call".
  288 + */
  289 + fun startScoAudio(): Boolean {
  290 + ThreadUtils.checkIsOnMainThread()
  291 + Timber.d(
  292 + "startSco: BT state=" + bluetoothState + ", "
  293 + + "attempts: " + scoConnectionAttempts + ", "
  294 + + "SCO is on: " + isScoOn
  295 + )
  296 + if (scoConnectionAttempts >= MAX_SCO_CONNECTION_ATTEMPTS) {
  297 + Timber.e("BT SCO connection fails - no more attempts")
  298 + return false
  299 + }
  300 + if (bluetoothState != State.HEADSET_AVAILABLE) {
  301 + Timber.e("BT SCO connection fails - no headset available")
  302 + return false
  303 + }
  304 + // Start BT SCO channel and wait for ACTION_AUDIO_STATE_CHANGED.
  305 + Timber.d("Starting Bluetooth SCO and waits for ACTION_AUDIO_STATE_CHANGED...")
  306 + // The SCO connection establishment can take several seconds, hence we cannot rely on the
  307 + // connection to be available when the method returns but instead register to receive the
  308 + // intent ACTION_SCO_AUDIO_STATE_UPDATED and wait for the state to be SCO_AUDIO_STATE_CONNECTED.
  309 + bluetoothState = State.SCO_CONNECTING
  310 + audioManager!!.startBluetoothSco()
  311 + audioManager.isBluetoothScoOn = true
  312 + scoConnectionAttempts++
  313 + startTimer()
  314 + Timber.d(
  315 + "startScoAudio done: BT state=" + bluetoothState + ", "
  316 + + "SCO is on: " + isScoOn
  317 + )
  318 + return true
  319 + }
  320 +
  321 + /** Stops Bluetooth SCO connection with remote device. */
  322 + fun stopScoAudio() {
  323 + ThreadUtils.checkIsOnMainThread()
  324 + Timber.d(
  325 + "stopScoAudio: BT state=" + bluetoothState + ", "
  326 + + "SCO is on: " + isScoOn
  327 + )
  328 + if (bluetoothState != State.SCO_CONNECTING && bluetoothState != State.SCO_CONNECTED) {
  329 + return
  330 + }
  331 + cancelTimer()
  332 + audioManager!!.stopBluetoothSco()
  333 + audioManager.isBluetoothScoOn = false
  334 + bluetoothState = State.SCO_DISCONNECTING
  335 + Timber.d(
  336 + "stopScoAudio done: BT state=" + bluetoothState + ", "
  337 + + "SCO is on: " + isScoOn
  338 + )
  339 + }
  340 +
  341 + /**
  342 + * Use the BluetoothHeadset proxy object (controls the Bluetooth Headset
  343 + * Service via IPC) to update the list of connected devices for the HEADSET
  344 + * profile. The internal state will change to HEADSET_UNAVAILABLE or to
  345 + * HEADSET_AVAILABLE and |bluetoothDevice| will be mapped to the connected
  346 + * device if available.
  347 + */
  348 + fun updateDevice() {
  349 + if (bluetoothState == State.UNINITIALIZED || bluetoothHeadset == null) {
  350 + return
  351 + }
  352 + Timber.d("updateDevice")
  353 + // Get connected devices for the headset profile. Returns the set of
  354 + // devices which are in state STATE_CONNECTED. The BluetoothDevice class
  355 + // is just a thin wrapper for a Bluetooth hardware address.
  356 + val devices = bluetoothHeadset!!.connectedDevices
  357 + if (devices.isEmpty()) {
  358 + bluetoothDevice = null
  359 + bluetoothState = State.HEADSET_UNAVAILABLE
  360 + Timber.d("No connected bluetooth headset")
  361 + } else {
  362 + // Always use first device in list. Android only supports one device.
  363 + bluetoothDevice = devices[0]
  364 + bluetoothState = State.HEADSET_AVAILABLE
  365 + Timber.d(
  366 + "Connected bluetooth headset: "
  367 + + "name=" + bluetoothDevice!!.name + ", "
  368 + + "state=" + stateToString(bluetoothHeadset!!.getConnectionState(bluetoothDevice))
  369 + + ", SCO audio=" + bluetoothHeadset!!.isAudioConnected(bluetoothDevice)
  370 + )
  371 + }
  372 + Timber.d("updateDevice done: BT state=$bluetoothState")
  373 + }
  374 +
  375 + /**
  376 + * Stubs for test mocks.
  377 + */
  378 + protected fun getAudioManager(context: Context): AudioManager? {
  379 + return context.getSystemService(Context.AUDIO_SERVICE) as AudioManager
  380 + }
  381 +
  382 + protected fun registerReceiver(receiver: BroadcastReceiver?, filter: IntentFilter?) {
  383 + apprtcContext.registerReceiver(receiver, filter)
  384 + }
  385 +
  386 + protected fun unregisterReceiver(receiver: BroadcastReceiver?) {
  387 + apprtcContext.unregisterReceiver(receiver)
  388 + }
  389 +
  390 + protected fun getBluetoothProfileProxy(
  391 + context: Context?, listener: BluetoothProfile.ServiceListener?, profile: Int
  392 + ): Boolean {
  393 + return bluetoothAdapter!!.getProfileProxy(context, listener, profile)
  394 + }
  395 +
  396 + protected fun hasPermission(context: Context?, permission: String?): Boolean {
  397 + return (apprtcContext.checkPermission(permission!!, Process.myPid(), Process.myUid())
  398 + == PackageManager.PERMISSION_GRANTED)
  399 + }
  400 +
  401 + /** Logs the state of the local Bluetooth adapter. */
  402 + @SuppressLint("HardwareIds")
  403 + protected fun logBluetoothAdapterInfo(localAdapter: BluetoothAdapter) {
  404 + Timber.d(
  405 + "BluetoothAdapter: "
  406 + + "enabled=" + localAdapter.isEnabled + ", "
  407 + + "state=" + stateToString(localAdapter.state) + ", "
  408 + + "name=" + localAdapter.name + ", "
  409 + + "address=" + localAdapter.address
  410 + )
  411 + // Log the set of BluetoothDevice objects that are bonded (paired) to the local adapter.
  412 + val pairedDevices = localAdapter.bondedDevices
  413 + if (!pairedDevices.isEmpty()) {
  414 + Timber.d("paired devices:")
  415 + for (device in pairedDevices) {
  416 + Timber.d(" name=" + device.name + ", address=" + device.address)
  417 + }
  418 + }
  419 + }
  420 +
  421 + /** Ensures that the audio manager updates its list of available audio devices. */
  422 + private fun updateAudioDeviceState() {
  423 + ThreadUtils.checkIsOnMainThread()
  424 + Timber.d("updateAudioDeviceState")
  425 + apprtcAudioManager.updateAudioDeviceState()
  426 + }
  427 +
  428 + /** Starts timer which times out after BLUETOOTH_SCO_TIMEOUT_MS milliseconds. */
  429 + private fun startTimer() {
  430 + ThreadUtils.checkIsOnMainThread()
  431 + Timber.d("startTimer")
  432 + handler.postDelayed(bluetoothTimeoutRunnable, BLUETOOTH_SCO_TIMEOUT_MS.toLong())
  433 + }
  434 +
  435 + /** Cancels any outstanding timer tasks. */
  436 + private fun cancelTimer() {
  437 + ThreadUtils.checkIsOnMainThread()
  438 + Timber.d("cancelTimer")
  439 + handler.removeCallbacks(bluetoothTimeoutRunnable)
  440 + }
  441 +
  442 + /**
  443 + * Called when start of the BT SCO channel takes too long time. Usually
  444 + * happens when the BT device has been turned on during an ongoing call.
  445 + */
  446 + private fun bluetoothTimeout() {
  447 + ThreadUtils.checkIsOnMainThread()
  448 + if (bluetoothState == State.UNINITIALIZED || bluetoothHeadset == null) {
  449 + return
  450 + }
  451 + Timber.d(
  452 + "bluetoothTimeout: BT state=" + bluetoothState + ", "
  453 + + "attempts: " + scoConnectionAttempts + ", "
  454 + + "SCO is on: " + isScoOn
  455 + )
  456 + if (bluetoothState != State.SCO_CONNECTING) {
  457 + return
  458 + }
  459 + // Bluetooth SCO should be connecting; check the latest result.
  460 + var scoConnected = false
  461 + val devices = bluetoothHeadset!!.connectedDevices
  462 + if (devices.size > 0) {
  463 + bluetoothDevice = devices[0]
  464 + if (bluetoothHeadset!!.isAudioConnected(bluetoothDevice)) {
  465 + Timber.d("SCO connected with " + bluetoothDevice!!.name)
  466 + scoConnected = true
  467 + } else {
  468 + Timber.d("SCO is not connected with " + bluetoothDevice!!.name)
  469 + }
  470 + }
  471 + if (scoConnected) {
  472 + // We thought BT had timed out, but it's actually on; updating state.
  473 + bluetoothState = State.SCO_CONNECTED
  474 + scoConnectionAttempts = 0
  475 + } else {
  476 + // Give up and "cancel" our request by calling stopBluetoothSco().
  477 + Timber.w("BT failed to connect after timeout")
  478 + stopScoAudio()
  479 + }
  480 + updateAudioDeviceState()
  481 + Timber.d("bluetoothTimeout done: BT state=$bluetoothState")
  482 + }
  483 +
  484 + /** Checks whether audio uses Bluetooth SCO. */
  485 + private val isScoOn: Boolean
  486 + private get() = audioManager!!.isBluetoothScoOn
  487 +
  488 + /** Converts BluetoothAdapter states into local string representations. */
  489 + private fun stateToString(state: Int): String {
  490 + return when (state) {
  491 + BluetoothAdapter.STATE_DISCONNECTED -> "DISCONNECTED"
  492 + BluetoothAdapter.STATE_CONNECTED -> "CONNECTED"
  493 + BluetoothAdapter.STATE_CONNECTING -> "CONNECTING"
  494 + BluetoothAdapter.STATE_DISCONNECTING -> "DISCONNECTING"
  495 + BluetoothAdapter.STATE_OFF -> "OFF"
  496 + BluetoothAdapter.STATE_ON -> "ON"
  497 + BluetoothAdapter.STATE_TURNING_OFF -> // Indicates the local Bluetooth adapter is turning off. Local clients should immediately
  498 + // attempt graceful disconnection of any remote links.
  499 + "TURNING_OFF"
  500 + BluetoothAdapter.STATE_TURNING_ON -> // Indicates the local Bluetooth adapter is turning on. However local clients should wait
  501 + // for STATE_ON before attempting to use the adapter.
  502 + "TURNING_ON"
  503 + else -> "INVALID"
  504 + }
  505 + }
  506 +
  507 + companion object {
  508 + private const val TAG = "AppRTCBluetoothManager"
  509 +
  510 + // Timeout interval for starting or stopping audio to a Bluetooth SCO device.
  511 + private const val BLUETOOTH_SCO_TIMEOUT_MS = 4000
  512 +
  513 + // Maximum number of SCO connection attempts.
  514 + private const val MAX_SCO_CONNECTION_ATTEMPTS = 2
  515 +
  516 + /** Construction. */
  517 + @JvmStatic
  518 + fun create(context: Context, audioManager: AppRTCAudioManager): AppRTCBluetoothManager {
  519 + Timber.d("create" + AppRTCUtils.threadInfo)
  520 + return AppRTCBluetoothManager(context, audioManager)
  521 + }
  522 + }
  523 +
  524 + init {
  525 + Timber.d("ctor")
  526 + ThreadUtils.checkIsOnMainThread()
  527 + apprtcContext = context
  528 + apprtcAudioManager = audioManager
  529 + this.audioManager = getAudioManager(context)
  530 + bluetoothState = State.UNINITIALIZED
  531 + bluetoothServiceListener = BluetoothServiceListener()
  532 + bluetoothHeadsetReceiver = BluetoothHeadsetBroadcastReceiver()
  533 + handler = Handler(Looper.getMainLooper())
  534 + }
  535 +}
  1 +/*
  2 + * Copyright 2014 The WebRTC Project Authors. All rights reserved.
  3 + *
  4 + * Use of this source code is governed by a BSD-style license
  5 + * that can be found in the LICENSE file in the root of the source
  6 + * tree. An additional intellectual property rights grant can be found
  7 + * in the file PATENTS. All contributing project authors may
  8 + * be found in the AUTHORS file in the root of the source tree.
  9 + */
  10 +package io.livekit.android.sample.audio
  11 +
  12 +import android.annotation.SuppressLint
  13 +import android.content.Context
  14 +import android.hardware.Sensor
  15 +import android.hardware.SensorEvent
  16 +import android.hardware.SensorEventListener
  17 +import android.hardware.SensorManager
  18 +import android.os.Build
  19 +import org.webrtc.ThreadUtils
  20 +import timber.log.Timber
  21 +
  22 +/**
  23 + * AppRTCProximitySensor manages functions related to the proximity sensor in
  24 + * the AppRTC demo.
  25 + * On most device, the proximity sensor is implemented as a boolean-sensor.
  26 + * It returns just two values "NEAR" or "FAR". Thresholding is done on the LUX
  27 + * value i.e. the LUX value of the light sensor is compared with a threshold.
  28 + * A LUX-value more than the threshold means the proximity sensor returns "FAR".
  29 + * Anything less than the threshold value and the sensor returns "NEAR".
  30 + */
  31 +class AppRTCProximitySensor
  32 +private constructor(context: Context, sensorStateListener: Runnable) : SensorEventListener {
  33 + // This class should be created, started and stopped on one thread
  34 + // (e.g. the main thread). We use |nonThreadSafe| to ensure that this is
  35 + // the case. Only active when |DEBUG| is set to true.
  36 + private val threadChecker = ThreadUtils.ThreadChecker()
  37 + private val onSensorStateListener: Runnable?
  38 + private val sensorManager: SensorManager
  39 + private var proximitySensor: Sensor? = null
  40 + private var lastStateReportIsNear = false
  41 +
  42 + init {
  43 + Timber.d("AppRTCProximitySensor ${AppRTCUtils.threadInfo}")
  44 + onSensorStateListener = sensorStateListener
  45 + sensorManager = context.getSystemService(Context.SENSOR_SERVICE) as SensorManager
  46 + }
  47 +
  48 + /**
  49 + * Activate the proximity sensor. Also do initialization if called for the
  50 + * first time.
  51 + */
  52 + fun start(): Boolean {
  53 + threadChecker.checkIsOnValidThread()
  54 + Timber.d("start ${AppRTCUtils.threadInfo}")
  55 + if (!initDefaultSensor()) {
  56 + // Proximity sensor is not supported on this device.
  57 + return false
  58 + }
  59 + sensorManager.registerListener(this, proximitySensor, SensorManager.SENSOR_DELAY_NORMAL)
  60 + return true
  61 + }
  62 +
  63 + /** Deactivate the proximity sensor. */
  64 + fun stop() {
  65 + threadChecker.checkIsOnValidThread()
  66 + Timber.d("stop ${AppRTCUtils.threadInfo}")
  67 + if (proximitySensor == null) {
  68 + return
  69 + }
  70 + sensorManager.unregisterListener(this, proximitySensor)
  71 + }
  72 +
  73 + /** Getter for last reported state. Set to true if "near" is reported. */
  74 + fun sensorReportsNearState(): Boolean {
  75 + threadChecker.checkIsOnValidThread()
  76 + return lastStateReportIsNear
  77 + }
  78 +
  79 + override fun onAccuracyChanged(sensor: Sensor, accuracy: Int) {
  80 + threadChecker.checkIsOnValidThread()
  81 + AppRTCUtils.assertIsTrue(sensor.type == Sensor.TYPE_PROXIMITY)
  82 + if (accuracy == SensorManager.SENSOR_STATUS_UNRELIABLE) {
  83 + Timber.e("The values returned by this sensor cannot be trusted")
  84 + }
  85 + }
  86 +
  87 + override fun onSensorChanged(event: SensorEvent) {
  88 + threadChecker.checkIsOnValidThread()
  89 + AppRTCUtils.assertIsTrue(event.sensor.type == Sensor.TYPE_PROXIMITY)
  90 + // As a best practice; do as little as possible within this method and
  91 + // avoid blocking.
  92 + val distanceInCentimeters = event.values[0]
  93 + lastStateReportIsNear = if (distanceInCentimeters < proximitySensor!!.maximumRange) {
  94 + Timber.d("Proximity sensor => NEAR state")
  95 + true
  96 + } else {
  97 + Timber.d("Proximity sensor => FAR state")
  98 + false
  99 + }
  100 +
  101 + // Report about new state to listening client. Client can then call
  102 + // sensorReportsNearState() to query the current state (NEAR or FAR).
  103 + onSensorStateListener?.run()
  104 + Timber.d(
  105 + "onSensorChanged ${AppRTCUtils.threadInfo}: accuracy=${event.accuracy}, timestamp=${event.timestamp}, distance=${event.values[0]}"
  106 + )
  107 + }
  108 +
  109 + /**
  110 + * Get default proximity sensor if it exists. Tablet devices (e.g. Nexus 7)
  111 + * does not support this type of sensor and false will be returned in such
  112 + * cases.
  113 + */
  114 + private fun initDefaultSensor(): Boolean {
  115 + if (proximitySensor != null) {
  116 + return true
  117 + }
  118 + proximitySensor = sensorManager.getDefaultSensor(Sensor.TYPE_PROXIMITY)
  119 + if (proximitySensor == null) {
  120 + return false
  121 + }
  122 + logProximitySensorInfo()
  123 + return true
  124 + }
  125 +
  126 + /** Helper method for logging information about the proximity sensor. */
  127 + @SuppressLint("ObsoleteSdkInt")
  128 + private fun logProximitySensorInfo() {
  129 + if (proximitySensor == null) {
  130 + return
  131 + }
  132 + val info = StringBuilder("Proximity sensor: ")
  133 + info.append("name=").append(proximitySensor!!.name)
  134 + info.append(", vendor: ").append(proximitySensor!!.vendor)
  135 + info.append(", power: ").append(proximitySensor!!.power)
  136 + info.append(", resolution: ").append(proximitySensor!!.resolution)
  137 + info.append(", max range: ").append(proximitySensor!!.maximumRange)
  138 + info.append(", min delay: ").append(proximitySensor!!.minDelay)
  139 + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.KITKAT_WATCH) {
  140 + // Added in API level 20.
  141 + info.append(", type: ").append(proximitySensor!!.stringType)
  142 + }
  143 + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP) {
  144 + // Added in API level 21.
  145 + info.append(", max delay: ").append(proximitySensor!!.maxDelay)
  146 + info.append(", reporting mode: ").append(proximitySensor!!.reportingMode)
  147 + info.append(", isWakeUpSensor: ").append(proximitySensor!!.isWakeUpSensor)
  148 + }
  149 + Timber.d(info.toString())
  150 + }
  151 +
  152 + companion object {
  153 +
  154 + /** Construction */
  155 + @JvmStatic
  156 + fun create(context: Context, sensorStateListener: Runnable): AppRTCProximitySensor {
  157 + return AppRTCProximitySensor(context, sensorStateListener)
  158 + }
  159 + }
  160 +}
  1 +/*
  2 + * Copyright 2014 The WebRTC Project Authors. All rights reserved.
  3 + *
  4 + * Use of this source code is governed by a BSD-style license
  5 + * that can be found in the LICENSE file in the root of the source
  6 + * tree. An additional intellectual property rights grant can be found
  7 + * in the file PATENTS. All contributing project authors may
  8 + * be found in the AUTHORS file in the root of the source tree.
  9 + */
  10 +package io.livekit.android.sample.audio
  11 +
  12 +import android.annotation.SuppressLint
  13 +import android.os.Build
  14 +import timber.log.Timber
  15 +
  16 +/**
  17 + * AppRTCUtils provides helper functions for managing thread safety.
  18 + */
  19 +object AppRTCUtils {
  20 + /** Helper method which throws an exception when an assertion has failed. */
  21 + fun assertIsTrue(condition: Boolean) {
  22 + if (!condition) {
  23 + throw AssertionError("Expected condition to be true")
  24 + }
  25 + }
  26 +
  27 + /** Helper method for building a string of thread information. */
  28 + val threadInfo: String
  29 + get() = ("@[name=" + Thread.currentThread().name + ", id=" + Thread.currentThread().id
  30 + + "]")
  31 +
  32 + /** Information about the current build, taken from system properties. */
  33 + @SuppressLint("BinaryOperationInTimber")
  34 + fun logDeviceInfo(tag: String?) {
  35 + Timber.tag(tag).d(
  36 + "Android SDK: " + Build.VERSION.SDK_INT + ", "
  37 + + "Release: " + Build.VERSION.RELEASE + ", "
  38 + + "Brand: " + Build.BRAND + ", "
  39 + + "Device: " + Build.DEVICE + ", "
  40 + + "Id: " + Build.ID + ", "
  41 + + "Hardware: " + Build.HARDWARE + ", "
  42 + + "Manufacturer: " + Build.MANUFACTURER + ", "
  43 + + "Model: " + Build.MODEL + ", "
  44 + + "Product: " + Build.PRODUCT
  45 + )
  46 + }
  47 +}
  1 +package io.livekit.android.sample.util
  2 +
  3 +import android.Manifest
  4 +import android.content.pm.PackageManager
  5 +import android.os.Build
  6 +import android.widget.Toast
  7 +import androidx.activity.ComponentActivity
  8 +import androidx.activity.result.contract.ActivityResultContracts
  9 +import androidx.core.content.ContextCompat
  10 +
  11 +fun ComponentActivity.requestNeededPermissions() {
  12 + val requestPermissionLauncher =
  13 + registerForActivityResult(
  14 + ActivityResultContracts.RequestMultiplePermissions()
  15 + ) { grants ->
  16 + for (grant in grants.entries) {
  17 + if (!grant.value) {
  18 + Toast.makeText(
  19 + this,
  20 + "Missing permission: ${grant.key}",
  21 + Toast.LENGTH_SHORT
  22 + )
  23 + .show()
  24 + }
  25 + }
  26 + }
  27 + val neededPermissions = listOf(Manifest.permission.RECORD_AUDIO, Manifest.permission.CAMERA)
  28 + .let { perms ->
  29 + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
  30 + perms + listOf(Manifest.permission.BLUETOOTH_CONNECT)
  31 + } else {
  32 + perms
  33 + }
  34 + }
  35 + .filter {
  36 + ContextCompat.checkSelfPermission(
  37 + this,
  38 + it
  39 + ) == PackageManager.PERMISSION_DENIED
  40 + }
  41 + .toTypedArray()
  42 +
  43 + if (neededPermissions.isNotEmpty()) {
  44 + requestPermissionLauncher.launch(neededPermissions)
  45 + }
  46 +}
@@ -2,10 +2,6 @@ @@ -2,10 +2,6 @@
2 <manifest xmlns:android="http://schemas.android.com/apk/res/android" 2 <manifest xmlns:android="http://schemas.android.com/apk/res/android"
3 package="io.livekit.android.composesample"> 3 package="io.livekit.android.composesample">
4 4
5 - <uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />  
6 - <uses-permission android:name="android.permission.INTERNET" />  
7 - <uses-permission android:name="android.permission.MODIFY_AUDIO_SETTINGS" />  
8 -  
9 <application 5 <application
10 android:allowBackup="true" 6 android:allowBackup="true"
11 android:name=".SampleApplication" 7 android:name=".SampleApplication"
1 package io.livekit.android.composesample 1 package io.livekit.android.composesample
2 2
3 import android.app.Activity 3 import android.app.Activity
4 -import android.media.AudioManager  
5 import android.media.projection.MediaProjectionManager 4 import android.media.projection.MediaProjectionManager
6 import android.os.Bundle 5 import android.os.Bundle
7 import android.os.Parcelable 6 import android.os.Parcelable
@@ -25,19 +24,15 @@ import androidx.compose.ui.unit.dp @@ -25,19 +24,15 @@ import androidx.compose.ui.unit.dp
25 import androidx.constraintlayout.compose.ConstraintLayout 24 import androidx.constraintlayout.compose.ConstraintLayout
26 import androidx.constraintlayout.compose.Dimension 25 import androidx.constraintlayout.compose.Dimension
27 import androidx.lifecycle.lifecycleScope 26 import androidx.lifecycle.lifecycleScope
28 -import com.github.ajalt.timberkt.Timber  
29 -import com.google.accompanist.pager.ExperimentalPagerApi  
30 import io.livekit.android.composesample.ui.DebugMenuDialog 27 import io.livekit.android.composesample.ui.DebugMenuDialog
31 import io.livekit.android.composesample.ui.theme.AppTheme 28 import io.livekit.android.composesample.ui.theme.AppTheme
32 import io.livekit.android.room.Room 29 import io.livekit.android.room.Room
33 import io.livekit.android.room.participant.Participant 30 import io.livekit.android.room.participant.Participant
34 import io.livekit.android.sample.CallViewModel 31 import io.livekit.android.sample.CallViewModel
35 import kotlinx.coroutines.Dispatchers 32 import kotlinx.coroutines.Dispatchers
36 -import kotlinx.coroutines.flow.collect  
37 import kotlinx.coroutines.launch 33 import kotlinx.coroutines.launch
38 import kotlinx.parcelize.Parcelize 34 import kotlinx.parcelize.Parcelize
39 35
40 -@OptIn(ExperimentalPagerApi::class)  
41 class CallActivity : AppCompatActivity() { 36 class CallActivity : AppCompatActivity() {
42 37
43 private val viewModel: CallViewModel by viewModelByFactory { 38 private val viewModel: CallViewModel by viewModelByFactory {
@@ -45,10 +40,6 @@ class CallActivity : AppCompatActivity() { @@ -45,10 +40,6 @@ class CallActivity : AppCompatActivity() {
45 ?: throw NullPointerException("args is null!") 40 ?: throw NullPointerException("args is null!")
46 CallViewModel(args.url, args.token, application) 41 CallViewModel(args.url, args.token, application)
47 } 42 }
48 - private val focusChangeListener = AudioManager.OnAudioFocusChangeListener {}  
49 -  
50 - private var previousSpeakerphoneOn = true  
51 - private var previousMicrophoneMute = false  
52 43
53 private val screenCaptureIntentLauncher = 44 private val screenCaptureIntentLauncher =
54 registerForActivityResult( 45 registerForActivityResult(
@@ -67,26 +58,6 @@ class CallActivity : AppCompatActivity() { @@ -67,26 +58,6 @@ class CallActivity : AppCompatActivity() {
67 override fun onCreate(savedInstanceState: Bundle?) { 58 override fun onCreate(savedInstanceState: Bundle?) {
68 super.onCreate(savedInstanceState) 59 super.onCreate(savedInstanceState)
69 60
70 - // Obtain audio focus.  
71 - val audioManager = getSystemService(AUDIO_SERVICE) as AudioManager  
72 - with(audioManager) {  
73 - previousSpeakerphoneOn = isSpeakerphoneOn  
74 - previousMicrophoneMute = isMicrophoneMute  
75 - isSpeakerphoneOn = true  
76 - isMicrophoneMute = false  
77 - mode = AudioManager.MODE_IN_COMMUNICATION  
78 - }  
79 - val result = audioManager.requestAudioFocus(  
80 - focusChangeListener,  
81 - AudioManager.STREAM_VOICE_CALL,  
82 - AudioManager.AUDIOFOCUS_GAIN,  
83 - )  
84 - if (result == AudioManager.AUDIOFOCUS_REQUEST_GRANTED) {  
85 - Timber.v { "Audio focus request granted for VOICE_CALL streams" }  
86 - } else {  
87 - Timber.v { "Audio focus request failed" }  
88 - }  
89 -  
90 // Setup compose view. 61 // Setup compose view.
91 setContent { 62 setContent {
92 val room by viewModel.room.collectAsState() 63 val room by viewModel.room.collectAsState()
@@ -451,19 +422,6 @@ class CallActivity : AppCompatActivity() { @@ -451,19 +422,6 @@ class CallActivity : AppCompatActivity() {
451 } 422 }
452 } 423 }
453 424
454 - override fun onDestroy() {  
455 - super.onDestroy()  
456 -  
457 - // release audio focus and revert audio settings.  
458 - val audioManager = getSystemService(AUDIO_SERVICE) as AudioManager  
459 - with(audioManager) {  
460 - isSpeakerphoneOn = previousSpeakerphoneOn  
461 - isMicrophoneMute = previousMicrophoneMute  
462 - abandonAudioFocus(focusChangeListener)  
463 - mode = AudioManager.MODE_NORMAL  
464 - }  
465 - }  
466 -  
467 companion object { 425 companion object {
468 const val KEY_ARGS = "args" 426 const val KEY_ARGS = "args"
469 } 427 }
1 package io.livekit.android.composesample 1 package io.livekit.android.composesample
2 2
3 -import android.Manifest  
4 import android.content.Intent 3 import android.content.Intent
5 -import android.content.pm.PackageManager  
6 import android.os.Bundle 4 import android.os.Bundle
7 import android.widget.Toast 5 import android.widget.Toast
8 import androidx.activity.ComponentActivity 6 import androidx.activity.ComponentActivity
9 import androidx.activity.compose.setContent 7 import androidx.activity.compose.setContent
10 -import androidx.activity.result.contract.ActivityResultContracts  
11 import androidx.activity.viewModels 8 import androidx.activity.viewModels
12 import androidx.compose.foundation.Image 9 import androidx.compose.foundation.Image
13 import androidx.compose.foundation.layout.* 10 import androidx.compose.foundation.layout.*
@@ -20,10 +17,10 @@ import androidx.compose.ui.Modifier @@ -20,10 +17,10 @@ import androidx.compose.ui.Modifier
20 import androidx.compose.ui.res.painterResource 17 import androidx.compose.ui.res.painterResource
21 import androidx.compose.ui.tooling.preview.Preview 18 import androidx.compose.ui.tooling.preview.Preview
22 import androidx.compose.ui.unit.dp 19 import androidx.compose.ui.unit.dp
23 -import androidx.core.content.ContextCompat  
24 import com.google.accompanist.pager.ExperimentalPagerApi 20 import com.google.accompanist.pager.ExperimentalPagerApi
25 import io.livekit.android.composesample.ui.theme.AppTheme 21 import io.livekit.android.composesample.ui.theme.AppTheme
26 import io.livekit.android.sample.MainViewModel 22 import io.livekit.android.sample.MainViewModel
  23 +import io.livekit.android.sample.util.requestNeededPermissions
27 24
28 @ExperimentalPagerApi 25 @ExperimentalPagerApi
29 class MainActivity : ComponentActivity() { 26 class MainActivity : ComponentActivity() {
@@ -32,7 +29,7 @@ class MainActivity : ComponentActivity() { @@ -32,7 +29,7 @@ class MainActivity : ComponentActivity() {
32 override fun onCreate(savedInstanceState: Bundle?) { 29 override fun onCreate(savedInstanceState: Bundle?) {
33 super.onCreate(savedInstanceState) 30 super.onCreate(savedInstanceState)
34 31
35 - requestPermissions() 32 + requestNeededPermissions()
36 setContent { 33 setContent {
37 MainContent( 34 MainContent(
38 defaultUrl = viewModel.getSavedUrl(), 35 defaultUrl = viewModel.getSavedUrl(),
@@ -145,34 +142,4 @@ class MainActivity : ComponentActivity() { @@ -145,34 +142,4 @@ class MainActivity : ComponentActivity() {
145 } 142 }
146 } 143 }
147 } 144 }
148 -  
149 - private fun requestPermissions() {  
150 - val requestPermissionLauncher =  
151 - registerForActivityResult(  
152 - ActivityResultContracts.RequestMultiplePermissions()  
153 - ) { grants ->  
154 - for (grant in grants.entries) {  
155 - if (!grant.value) {  
156 - Toast.makeText(  
157 - this,  
158 - "Missing permission: ${grant.key}",  
159 - Toast.LENGTH_SHORT  
160 - )  
161 - .show()  
162 - }  
163 - }  
164 - }  
165 - val neededPermissions = listOf(Manifest.permission.RECORD_AUDIO, Manifest.permission.CAMERA)  
166 - .filter {  
167 - ContextCompat.checkSelfPermission(  
168 - this,  
169 - it  
170 - ) == PackageManager.PERMISSION_DENIED  
171 - }  
172 - .toTypedArray()  
173 - if (neededPermissions.isNotEmpty()) {  
174 - requestPermissionLauncher.launch(neededPermissions)  
175 - }  
176 - }  
177 -  
178 } 145 }
1 <manifest xmlns:android="http://schemas.android.com/apk/res/android" 1 <manifest xmlns:android="http://schemas.android.com/apk/res/android"
2 package="io.livekit.android.sample"> 2 package="io.livekit.android.sample">
3 3
4 - <uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />  
5 - <uses-permission android:name="android.permission.INTERNET" />  
6 - <uses-permission android:name="android.permission.MODIFY_AUDIO_SETTINGS" />  
7 -  
8 <application 4 <application
9 android:name=".SampleApplication" 5 android:name=".SampleApplication"
10 android:networkSecurityConfig="@xml/network_security_config" 6 android:networkSecurityConfig="@xml/network_security_config"
1 package io.livekit.android.sample 1 package io.livekit.android.sample
2 2
3 import android.app.Activity 3 import android.app.Activity
4 -import android.media.AudioManager  
5 import android.media.projection.MediaProjectionManager 4 import android.media.projection.MediaProjectionManager
6 import android.os.Bundle 5 import android.os.Bundle
7 import android.os.Parcelable 6 import android.os.Parcelable
@@ -13,7 +12,6 @@ import androidx.appcompat.app.AlertDialog @@ -13,7 +12,6 @@ import androidx.appcompat.app.AlertDialog
13 import androidx.appcompat.app.AppCompatActivity 12 import androidx.appcompat.app.AppCompatActivity
14 import androidx.lifecycle.lifecycleScope 13 import androidx.lifecycle.lifecycleScope
15 import androidx.recyclerview.widget.LinearLayoutManager 14 import androidx.recyclerview.widget.LinearLayoutManager
16 -import com.github.ajalt.timberkt.Timber  
17 import com.xwray.groupie.GroupieAdapter 15 import com.xwray.groupie.GroupieAdapter
18 import io.livekit.android.room.track.Track 16 import io.livekit.android.room.track.Track
19 import io.livekit.android.room.track.VideoTrack 17 import io.livekit.android.room.track.VideoTrack
@@ -30,11 +28,6 @@ class CallActivity : AppCompatActivity() { @@ -30,11 +28,6 @@ class CallActivity : AppCompatActivity() {
30 CallViewModel(args.url, args.token, application) 28 CallViewModel(args.url, args.token, application)
31 } 29 }
32 lateinit var binding: CallActivityBinding 30 lateinit var binding: CallActivityBinding
33 - val focusChangeListener = AudioManager.OnAudioFocusChangeListener {}  
34 -  
35 - private var previousSpeakerphoneOn = true  
36 - private var previousMicrophoneMute = false  
37 -  
38 private val screenCaptureIntentLauncher = 31 private val screenCaptureIntentLauncher =
39 registerForActivityResult( 32 registerForActivityResult(
40 ActivityResultContracts.StartActivityForResult() 33 ActivityResultContracts.StartActivityForResult()
@@ -180,26 +173,6 @@ class CallActivity : AppCompatActivity() { @@ -180,26 +173,6 @@ class CallActivity : AppCompatActivity() {
180 } 173 }
181 174
182 binding.exit.setOnClickListener { finish() } 175 binding.exit.setOnClickListener { finish() }
183 -  
184 - // Grab audio focus for video call  
185 - val audioManager = getSystemService(AUDIO_SERVICE) as AudioManager  
186 - with(audioManager) {  
187 - previousSpeakerphoneOn = isSpeakerphoneOn  
188 - previousMicrophoneMute = isMicrophoneMute  
189 - isSpeakerphoneOn = true  
190 - isMicrophoneMute = false  
191 - mode = AudioManager.MODE_IN_COMMUNICATION  
192 - }  
193 - val result = audioManager.requestAudioFocus(  
194 - focusChangeListener,  
195 - AudioManager.STREAM_VOICE_CALL,  
196 - AudioManager.AUDIOFOCUS_GAIN,  
197 - )  
198 - if (result == AudioManager.AUDIOFOCUS_REQUEST_GRANTED) {  
199 - Timber.v { "Audio focus request granted for VOICE_CALL streams" }  
200 - } else {  
201 - Timber.v { "Audio focus request failed" }  
202 - }  
203 } 176 }
204 177
205 override fun onResume() { 178 override fun onResume() {
@@ -231,15 +204,6 @@ class CallActivity : AppCompatActivity() { @@ -231,15 +204,6 @@ class CallActivity : AppCompatActivity() {
231 204
232 // Release video views 205 // Release video views
233 binding.speakerVideoView.release() 206 binding.speakerVideoView.release()
234 -  
235 - // Undo audio mode changes  
236 - val audioManager = getSystemService(AUDIO_SERVICE) as AudioManager  
237 - with(audioManager) {  
238 - isSpeakerphoneOn = previousSpeakerphoneOn  
239 - isMicrophoneMute = previousMicrophoneMute  
240 - abandonAudioFocus(focusChangeListener)  
241 - mode = AudioManager.MODE_NORMAL  
242 - }  
243 } 207 }
244 208
245 companion object { 209 companion object {
@@ -3,6 +3,7 @@ package io.livekit.android.sample @@ -3,6 +3,7 @@ package io.livekit.android.sample
3 import android.Manifest 3 import android.Manifest
4 import android.content.Intent 4 import android.content.Intent
5 import android.content.pm.PackageManager 5 import android.content.pm.PackageManager
  6 +import android.os.Build
6 import android.os.Bundle 7 import android.os.Bundle
7 import android.text.SpannableStringBuilder 8 import android.text.SpannableStringBuilder
8 import android.widget.Toast 9 import android.widget.Toast
@@ -11,6 +12,7 @@ import androidx.activity.viewModels @@ -11,6 +12,7 @@ import androidx.activity.viewModels
11 import androidx.appcompat.app.AppCompatActivity 12 import androidx.appcompat.app.AppCompatActivity
12 import androidx.core.content.ContextCompat 13 import androidx.core.content.ContextCompat
13 import io.livekit.android.sample.databinding.MainActivityBinding 14 import io.livekit.android.sample.databinding.MainActivityBinding
  15 +import io.livekit.android.sample.util.requestNeededPermissions
14 16
15 17
16 class MainActivity : AppCompatActivity() { 18 class MainActivity : AppCompatActivity() {
@@ -67,36 +69,6 @@ class MainActivity : AppCompatActivity() { @@ -67,36 +69,6 @@ class MainActivity : AppCompatActivity() {
67 69
68 setContentView(binding.root) 70 setContentView(binding.root)
69 71
70 - requestPermissions()  
71 -  
72 - }  
73 -  
74 - private fun requestPermissions() {  
75 - val requestPermissionLauncher =  
76 - registerForActivityResult(  
77 - ActivityResultContracts.RequestMultiplePermissions()  
78 - ) { grants ->  
79 - for (grant in grants.entries) {  
80 - if (!grant.value) {  
81 - Toast.makeText(  
82 - this,  
83 - "Missing permission: ${grant.key}",  
84 - Toast.LENGTH_SHORT  
85 - )  
86 - .show()  
87 - }  
88 - }  
89 - }  
90 - val neededPermissions = listOf(Manifest.permission.RECORD_AUDIO, Manifest.permission.CAMERA)  
91 - .filter {  
92 - ContextCompat.checkSelfPermission(  
93 - this,  
94 - it  
95 - ) == PackageManager.PERMISSION_DENIED  
96 - }  
97 - .toTypedArray()  
98 - if (neededPermissions.isNotEmpty()) {  
99 - requestPermissionLauncher.launch(neededPermissions)  
100 - } 72 + requestNeededPermissions()
101 } 73 }
102 } 74 }