davidliu
Committed by GitHub

Audio device selection (#106)

* Expose AudioSwitch controls

* fix build
@@ -16,5 +16,8 @@ @@ -16,5 +16,8 @@
16 <inspection_tool class="PreviewNeedsComposableAnnotation" enabled="true" level="ERROR" enabled_by_default="true"> 16 <inspection_tool class="PreviewNeedsComposableAnnotation" enabled="true" level="ERROR" enabled_by_default="true">
17 <option name="previewFile" value="true" /> 17 <option name="previewFile" value="true" />
18 </inspection_tool> 18 </inspection_tool>
  19 + <inspection_tool class="PreviewPickerAnnotation" enabled="true" level="ERROR" enabled_by_default="true">
  20 + <option name="previewFile" value="true" />
  21 + </inspection_tool>
19 </profile> 22 </profile>
20 </component> 23 </component>
@@ -113,7 +113,7 @@ dependencies { @@ -113,7 +113,7 @@ dependencies {
113 implementation 'org.jetbrains.kotlinx:kotlinx-serialization-json:1.1.0' 113 implementation 'org.jetbrains.kotlinx:kotlinx-serialization-json:1.1.0'
114 api 'com.github.webrtc-sdk:android:97.4692.04' 114 api 'com.github.webrtc-sdk:android:97.4692.04'
115 api "com.squareup.okhttp3:okhttp:4.9.1" 115 api "com.squareup.okhttp3:okhttp:4.9.1"
116 - implementation "com.twilio:audioswitch:1.1.5" 116 + api "com.twilio:audioswitch:1.1.5"
117 implementation "androidx.annotation:annotation:1.3.0" 117 implementation "androidx.annotation:annotation:1.3.0"
118 implementation "androidx.core:core:${versions.androidx_core}" 118 implementation "androidx.core:core:${versions.androidx_core}"
119 implementation "com.google.protobuf:protobuf-javalite:${versions.protobuf}" 119 implementation "com.google.protobuf:protobuf-javalite:${versions.protobuf}"
@@ -40,7 +40,9 @@ data class LiveKitOverrides( @@ -40,7 +40,9 @@ data class LiveKitOverrides(
40 val videoDecoderFactory: VideoDecoderFactory? = null, 40 val videoDecoderFactory: VideoDecoderFactory? = null,
41 41
42 /** 42 /**
43 - * Override the default [AudioHandler]. Use [NoAudioHandler] to turn off automatic audio handling. 43 + * Override the default [AudioHandler].
  44 + *
  45 + * Use [NoAudioHandler] to turn off automatic audio handling.
44 */ 46 */
45 47
46 val audioHandler: AudioHandler? = null 48 val audioHandler: AudioHandler? = null
1 package io.livekit.android.audio 1 package io.livekit.android.audio
2 2
3 import android.content.Context 3 import android.content.Context
  4 +import android.media.AudioManager
  5 +import com.twilio.audioswitch.AudioDevice
  6 +import com.twilio.audioswitch.AudioDeviceChangeListener
4 import com.twilio.audioswitch.AudioSwitch 7 import com.twilio.audioswitch.AudioSwitch
5 import javax.inject.Inject 8 import javax.inject.Inject
6 import javax.inject.Singleton 9 import javax.inject.Singleton
@@ -8,14 +11,60 @@ import javax.inject.Singleton @@ -8,14 +11,60 @@ import javax.inject.Singleton
8 @Singleton 11 @Singleton
9 class AudioSwitchHandler 12 class AudioSwitchHandler
10 @Inject 13 @Inject
11 -constructor(context: Context) : AudioHandler {  
12 - private val audioSwitch = AudioSwitch(context) 14 +constructor(private val context: Context) : AudioHandler {
  15 + var loggingEnabled = false
  16 + var audioDeviceChangeListener: AudioDeviceChangeListener? = null
  17 + var onAudioFocusChangeListener: AudioManager.OnAudioFocusChangeListener? = null
  18 + var preferredDeviceList: List<Class<out AudioDevice>>? = null
  19 +
  20 + private var audioSwitch: AudioSwitch? = null
  21 +
13 override fun start() { 22 override fun start() {
14 - audioSwitch.start { _, _ -> }  
15 - audioSwitch.activate() 23 + if (audioSwitch == null) {
  24 + val switch = AudioSwitch(
  25 + context = context,
  26 + loggingEnabled = loggingEnabled,
  27 + audioFocusChangeListener = onAudioFocusChangeListener ?: defaultOnAudioFocusChangeListener,
  28 + preferredDeviceList = preferredDeviceList ?: defaultPreferredDeviceList
  29 + )
  30 + audioSwitch = switch
  31 + switch.start(audioDeviceChangeListener ?: defaultAudioDeviceChangeListener)
  32 + switch.activate()
  33 + }
16 } 34 }
17 35
18 override fun stop() { 36 override fun stop() {
19 - audioSwitch.stop() 37 + audioSwitch?.stop()
  38 + audioSwitch = null
  39 + }
  40 +
  41 + val selectedAudioDevice: AudioDevice?
  42 + get() = audioSwitch?.selectedAudioDevice
  43 +
  44 + val availableAudioDevices: List<AudioDevice>
  45 + get() = audioSwitch?.availableAudioDevices ?: listOf()
  46 +
  47 + fun selectDevice(audioDevice: AudioDevice?) {
  48 + audioSwitch?.selectDevice(audioDevice)
  49 + }
  50 +
  51 + companion object {
  52 + private val defaultOnAudioFocusChangeListener by lazy(LazyThreadSafetyMode.NONE) {
  53 + AudioManager.OnAudioFocusChangeListener { }
  54 + }
  55 + private val defaultAudioDeviceChangeListener by lazy(LazyThreadSafetyMode.NONE) {
  56 + object : AudioDeviceChangeListener {
  57 + override fun invoke(audioDevices: List<AudioDevice>, selectedAudioDevice: AudioDevice?) {
  58 + }
  59 + }
  60 + }
  61 + private val defaultPreferredDeviceList by lazy(LazyThreadSafetyMode.NONE) {
  62 + listOf(
  63 + AudioDevice.BluetoothHeadset::class.java,
  64 + AudioDevice.WiredHeadset::class.java,
  65 + AudioDevice.Earpiece::class.java,
  66 + AudioDevice.Speakerphone::class.java
  67 + )
  68 + }
20 } 69 }
21 } 70 }
@@ -44,7 +44,7 @@ constructor( @@ -44,7 +44,7 @@ constructor(
44 private val defaultDispatcher: CoroutineDispatcher, 44 private val defaultDispatcher: CoroutineDispatcher,
45 @Named(InjectionNames.DISPATCHER_IO) 45 @Named(InjectionNames.DISPATCHER_IO)
46 private val ioDispatcher: CoroutineDispatcher, 46 private val ioDispatcher: CoroutineDispatcher,
47 - private val audioHandler: AudioHandler, 47 + val audioHandler: AudioHandler,
48 ) : RTCEngine.Listener, ParticipantListener, ConnectivityManager.NetworkCallback() { 48 ) : RTCEngine.Listener, ParticipantListener, ConnectivityManager.NetworkCallback() {
49 49
50 private lateinit var coroutineScope: CoroutineScope 50 private lateinit var coroutineScope: CoroutineScope
@@ -8,7 +8,9 @@ import androidx.lifecycle.MutableLiveData @@ -8,7 +8,9 @@ import androidx.lifecycle.MutableLiveData
8 import androidx.lifecycle.viewModelScope 8 import androidx.lifecycle.viewModelScope
9 import com.github.ajalt.timberkt.Timber 9 import com.github.ajalt.timberkt.Timber
10 import io.livekit.android.LiveKit 10 import io.livekit.android.LiveKit
  11 +import io.livekit.android.LiveKitOverrides
11 import io.livekit.android.RoomOptions 12 import io.livekit.android.RoomOptions
  13 +import io.livekit.android.audio.AudioSwitchHandler
12 import io.livekit.android.events.RoomEvent 14 import io.livekit.android.events.RoomEvent
13 import io.livekit.android.events.collect 15 import io.livekit.android.events.collect
14 import io.livekit.android.room.Room 16 import io.livekit.android.room.Room
@@ -79,6 +81,7 @@ class CallViewModel( @@ -79,6 +81,7 @@ class CallViewModel(
79 private val mutablePermissionAllowed = MutableStateFlow(true) 81 private val mutablePermissionAllowed = MutableStateFlow(true)
80 val permissionAllowed = mutablePermissionAllowed.hide() 82 val permissionAllowed = mutablePermissionAllowed.hide()
81 83
  84 + val audioHandler = AudioSwitchHandler(application)
82 init { 85 init {
83 viewModelScope.launch { 86 viewModelScope.launch {
84 87
@@ -92,6 +95,7 @@ class CallViewModel( @@ -92,6 +95,7 @@ class CallViewModel(
92 url, 95 url,
93 token, 96 token,
94 roomOptions = RoomOptions(adaptiveStream = true, dynacast = true), 97 roomOptions = RoomOptions(adaptiveStream = true, dynacast = true),
  98 + overrides = LiveKitOverrides(audioHandler = audioHandler)
95 ) 99 )
96 100
97 // Create and publish audio/video tracks 101 // Create and publish audio/video tracks
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 +<vector xmlns:android="http://schemas.android.com/apk/res/android"
  2 + android:width="48dp"
  3 + android:height="48dp"
  4 + android:viewportWidth="48"
  5 + android:viewportHeight="48"
  6 + android:tint="?attr/colorControlNormal">
  7 + <path
  8 + android:fillColor="@android:color/white"
  9 + android:pathData="M28,41.45V38.35Q32.85,36.95 35.925,32.975Q39,29 39,23.95Q39,18.9 35.95,14.9Q32.9,10.9 28,9.55V6.45Q34.2,7.85 38.1,12.725Q42,17.6 42,23.95Q42,30.3 38.1,35.175Q34.2,40.05 28,41.45ZM6,30V18H14L24,8V40L14,30ZM27,32.4V15.55Q29.7,16.4 31.35,18.75Q33,21.1 33,24Q33,26.95 31.35,29.25Q29.7,31.55 27,32.4ZM21,15.6 L15.35,21H9V27H15.35L21,32.45ZM16.3,24Z"/>
  10 +</vector>
@@ -24,7 +24,9 @@ import androidx.compose.ui.unit.dp @@ -24,7 +24,9 @@ import androidx.compose.ui.unit.dp
24 import androidx.constraintlayout.compose.ConstraintLayout 24 import androidx.constraintlayout.compose.ConstraintLayout
25 import androidx.constraintlayout.compose.Dimension 25 import androidx.constraintlayout.compose.Dimension
26 import androidx.lifecycle.lifecycleScope 26 import androidx.lifecycle.lifecycleScope
  27 +import io.livekit.android.audio.AudioSwitchHandler
27 import io.livekit.android.composesample.ui.DebugMenuDialog 28 import io.livekit.android.composesample.ui.DebugMenuDialog
  29 +import io.livekit.android.composesample.ui.SelectAudioDeviceDialog
28 import io.livekit.android.composesample.ui.theme.AppTheme 30 import io.livekit.android.composesample.ui.theme.AppTheme
29 import io.livekit.android.room.Room 31 import io.livekit.android.room.Room
30 import io.livekit.android.room.participant.Participant 32 import io.livekit.android.room.participant.Participant
@@ -78,6 +80,7 @@ class CallActivity : AppCompatActivity() { @@ -78,6 +80,7 @@ class CallActivity : AppCompatActivity() {
78 videoEnabled, 80 videoEnabled,
79 flipButtonEnabled, 81 flipButtonEnabled,
80 screencastEnabled, 82 screencastEnabled,
  83 + audioSwitchHandler = viewModel.audioHandler,
81 permissionAllowed = permissionAllowed, 84 permissionAllowed = permissionAllowed,
82 onExitClick = { finish() }, 85 onExitClick = { finish() },
83 onSendMessage = { viewModel.sendData(it) }, 86 onSendMessage = { viewModel.sendData(it) },
@@ -126,6 +129,7 @@ class CallActivity : AppCompatActivity() { @@ -126,6 +129,7 @@ class CallActivity : AppCompatActivity() {
126 flipButtonEnabled: Boolean = true, 129 flipButtonEnabled: Boolean = true,
127 screencastEnabled: Boolean = false, 130 screencastEnabled: Boolean = false,
128 permissionAllowed: Boolean = true, 131 permissionAllowed: Boolean = true,
  132 + audioSwitchHandler: AudioSwitchHandler? = null,
129 onExitClick: () -> Unit = {}, 133 onExitClick: () -> Unit = {},
130 error: Throwable? = null, 134 error: Throwable? = null,
131 onSnackbarDismiss: () -> Unit = {}, 135 onSnackbarDismiss: () -> Unit = {},
@@ -349,6 +353,29 @@ class CallActivity : AppCompatActivity() { @@ -349,6 +353,29 @@ class CallActivity : AppCompatActivity() {
349 horizontalArrangement = Arrangement.SpaceEvenly, 353 horizontalArrangement = Arrangement.SpaceEvenly,
350 verticalAlignment = Alignment.Bottom, 354 verticalAlignment = Alignment.Bottom,
351 ) { 355 ) {
  356 + var showAudioDeviceDialog by remember { mutableStateOf(false) }
  357 + Surface(
  358 + onClick = { showAudioDeviceDialog = true },
  359 + indication = rememberRipple(false),
  360 + modifier = Modifier
  361 + .size(controlSize)
  362 + .padding(controlPadding)
  363 + ) {
  364 + val resource = R.drawable.volume_up_48px
  365 + Icon(
  366 + painterResource(id = resource),
  367 + contentDescription = "Select Audio Device",
  368 + tint = Color.White,
  369 + )
  370 + }
  371 + if (showAudioDeviceDialog) {
  372 + SelectAudioDeviceDialog(
  373 + onDismissRequest = { showAudioDeviceDialog = false },
  374 + selectDevice = { audioSwitchHandler?.selectDevice(it) },
  375 + currentDevice = audioSwitchHandler?.selectedAudioDevice,
  376 + availableDevices = audioSwitchHandler?.availableAudioDevices ?: emptyList()
  377 + )
  378 + }
352 Surface( 379 Surface(
353 onClick = { viewModel.toggleSubscriptionPermissions() }, 380 onClick = { viewModel.toggleSubscriptionPermissions() },
354 indication = rememberRipple(false), 381 indication = rememberRipple(false),
  1 +package io.livekit.android.composesample.ui
  2 +
  3 +import androidx.compose.foundation.background
  4 +import androidx.compose.foundation.layout.*
  5 +import androidx.compose.foundation.lazy.LazyColumn
  6 +import androidx.compose.foundation.lazy.items
  7 +import androidx.compose.foundation.shape.RoundedCornerShape
  8 +import androidx.compose.material.Button
  9 +import androidx.compose.material.Text
  10 +import androidx.compose.runtime.Composable
  11 +import androidx.compose.ui.Alignment
  12 +import androidx.compose.ui.Modifier
  13 +import androidx.compose.ui.graphics.Color
  14 +import androidx.compose.ui.tooling.preview.Preview
  15 +import androidx.compose.ui.unit.dp
  16 +import androidx.compose.ui.window.Dialog
  17 +import com.twilio.audioswitch.AudioDevice
  18 +
  19 +@Preview
  20 +@Composable
  21 +fun SelectAudioDeviceDialog(
  22 + onDismissRequest: () -> Unit = {},
  23 + selectDevice: (AudioDevice) -> Unit = {},
  24 + currentDevice: AudioDevice? = null,
  25 + availableDevices: List<AudioDevice> = emptyList()
  26 +) {
  27 + Dialog(onDismissRequest = onDismissRequest) {
  28 + Column(
  29 + horizontalAlignment = Alignment.CenterHorizontally,
  30 + modifier = Modifier
  31 + .background(Color.DarkGray, shape = RoundedCornerShape(3.dp))
  32 + .fillMaxWidth()
  33 + .padding(10.dp)
  34 + ) {
  35 + Text("Select Audio Device", color = Color.White)
  36 + Spacer(modifier = Modifier.height(10.dp))
  37 +
  38 + LazyColumn {
  39 + items(availableDevices) { device ->
  40 + Button(
  41 + onClick = {
  42 + selectDevice(device)
  43 + onDismissRequest()
  44 + },
  45 + modifier = Modifier.fillMaxWidth()
  46 + ) {
  47 + Text(device.name)
  48 + }
  49 + }
  50 + }
  51 + }
  52 + }
  53 +}