Committed by
GitHub
Audio device selection (#106)
* Expose AudioSwitch controls * fix build
正在显示
13 个修改的文件
包含
156 行增加
和
1285 行删除
| @@ -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 |
sample-app-common/src/main/java/io/livekit/android/sample/audio/AppRTCAudioManager.kt
已删除
100644 → 0
| 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 | -} |
sample-app-common/src/main/java/io/livekit/android/sample/audio/AppRTCBluetoothManager.kt
已删除
100644 → 0
| 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 | -} |
sample-app-common/src/main/java/io/livekit/android/sample/audio/AppRTCProximitySensor.kt
已删除
100644 → 0
| 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), |
sample-app-compose/src/main/java/io/livekit/android/composesample/ui/SelectAudioDeviceDialog.kt
0 → 100644
| 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 | +} |
-
请 注册 或 登录 后发表评论