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