365 lines
12 KiB
Kotlin
365 lines
12 KiB
Kotlin
|
package org.thoughtcrime.securesms.webrtc.audio
|
||
|
|
||
|
import android.Manifest
|
||
|
import android.bluetooth.BluetoothAdapter
|
||
|
import android.bluetooth.BluetoothDevice
|
||
|
import android.bluetooth.BluetoothHeadset
|
||
|
import android.bluetooth.BluetoothProfile
|
||
|
import android.content.BroadcastReceiver
|
||
|
import android.content.Context
|
||
|
import android.content.Intent
|
||
|
import android.content.IntentFilter
|
||
|
import android.media.AudioManager
|
||
|
import org.session.libsignal.utilities.Log
|
||
|
import org.thoughtcrime.securesms.webrtc.AudioManagerCommand
|
||
|
import java.util.concurrent.TimeUnit
|
||
|
|
||
|
/**
|
||
|
* Manages the bluetooth lifecycle with a headset. This class doesn't make any
|
||
|
* determination on if bluetooth should be used. It determines if a device is connected,
|
||
|
* reports that to the [SignalAudioManager], and then handles connecting/disconnecting
|
||
|
* to the device if requested by [SignalAudioManager].
|
||
|
*/
|
||
|
class SignalBluetoothManager(
|
||
|
private val context: Context,
|
||
|
private val audioManager: SignalAudioManager,
|
||
|
private val androidAudioManager: AudioManagerCompat,
|
||
|
private val handler: SignalAudioHandler
|
||
|
) {
|
||
|
|
||
|
var state: State = State.UNINITIALIZED
|
||
|
get() {
|
||
|
handler.assertHandlerThread()
|
||
|
return field
|
||
|
}
|
||
|
private set
|
||
|
|
||
|
private var bluetoothAdapter: BluetoothAdapter? = null
|
||
|
private var bluetoothHeadset: BluetoothHeadset? = null
|
||
|
private var scoConnectionAttempts = 0
|
||
|
|
||
|
private val bluetoothListener = BluetoothServiceListener()
|
||
|
private var bluetoothReceiver: BluetoothHeadsetBroadcastReceiver? = null
|
||
|
|
||
|
private val bluetoothTimeout = { onBluetoothTimeout() }
|
||
|
|
||
|
fun start() {
|
||
|
handler.assertHandlerThread()
|
||
|
|
||
|
Log.d(TAG, "start(): $state")
|
||
|
|
||
|
if (state != State.UNINITIALIZED) {
|
||
|
Log.w(TAG, "Invalid starting state")
|
||
|
return
|
||
|
}
|
||
|
|
||
|
bluetoothHeadset = null
|
||
|
scoConnectionAttempts = 0
|
||
|
|
||
|
bluetoothAdapter = BluetoothAdapter.getDefaultAdapter()
|
||
|
if (bluetoothAdapter == null) {
|
||
|
Log.i(TAG, "Device does not support Bluetooth")
|
||
|
return
|
||
|
}
|
||
|
|
||
|
if (!androidAudioManager.isBluetoothScoAvailableOffCall) {
|
||
|
Log.w(TAG, "Bluetooth SCO audio is not available off call")
|
||
|
return
|
||
|
}
|
||
|
|
||
|
if (bluetoothAdapter?.getProfileProxy(context, bluetoothListener, BluetoothProfile.HEADSET) != true) {
|
||
|
Log.e(TAG, "BluetoothAdapter.getProfileProxy(HEADSET) failed")
|
||
|
return
|
||
|
}
|
||
|
|
||
|
val bluetoothHeadsetFilter = IntentFilter().apply {
|
||
|
addAction(BluetoothHeadset.ACTION_CONNECTION_STATE_CHANGED)
|
||
|
addAction(AudioManager.ACTION_SCO_AUDIO_STATE_UPDATED)
|
||
|
}
|
||
|
|
||
|
bluetoothReceiver = BluetoothHeadsetBroadcastReceiver()
|
||
|
context.registerReceiver(bluetoothReceiver, bluetoothHeadsetFilter)
|
||
|
|
||
|
Log.i(TAG, "Headset profile state: ${bluetoothAdapter?.getProfileConnectionState(BluetoothProfile.HEADSET)?.toStateString()}")
|
||
|
Log.i(TAG, "Bluetooth proxy for headset profile has started")
|
||
|
state = State.UNAVAILABLE
|
||
|
}
|
||
|
|
||
|
fun stop() {
|
||
|
handler.assertHandlerThread()
|
||
|
|
||
|
Log.d(TAG, "stop(): state: $state")
|
||
|
|
||
|
if (bluetoothAdapter == null) {
|
||
|
return
|
||
|
}
|
||
|
|
||
|
stopScoAudio()
|
||
|
|
||
|
if (state == State.UNINITIALIZED) {
|
||
|
return
|
||
|
}
|
||
|
|
||
|
bluetoothReceiver?.let { receiver ->
|
||
|
try {
|
||
|
context.unregisterReceiver(receiver)
|
||
|
} catch (e: Exception) {
|
||
|
Log.e(TAG,"error unregistering bluetoothReceiver", e)
|
||
|
}
|
||
|
}
|
||
|
bluetoothReceiver = null
|
||
|
|
||
|
cancelTimer()
|
||
|
|
||
|
bluetoothAdapter?.closeProfileProxy(BluetoothProfile.HEADSET, bluetoothHeadset)
|
||
|
bluetoothHeadset = null
|
||
|
|
||
|
bluetoothAdapter = null
|
||
|
state = State.UNINITIALIZED
|
||
|
}
|
||
|
|
||
|
fun startScoAudio(): Boolean {
|
||
|
handler.assertHandlerThread()
|
||
|
|
||
|
Log.i(TAG, "startScoAudio(): $state attempts: $scoConnectionAttempts")
|
||
|
|
||
|
if (scoConnectionAttempts >= MAX_CONNECTION_ATTEMPTS) {
|
||
|
Log.w(TAG, "SCO connection attempts maxed out")
|
||
|
return false
|
||
|
}
|
||
|
|
||
|
if (state != State.AVAILABLE) {
|
||
|
Log.w(TAG, "SCO connection failed as no headset available")
|
||
|
return false
|
||
|
}
|
||
|
|
||
|
state = State.CONNECTING
|
||
|
androidAudioManager.startBluetoothSco()
|
||
|
scoConnectionAttempts++
|
||
|
startTimer()
|
||
|
|
||
|
return true
|
||
|
}
|
||
|
|
||
|
fun stopScoAudio() {
|
||
|
handler.assertHandlerThread()
|
||
|
|
||
|
Log.i(TAG, "stopScoAudio(): $state")
|
||
|
|
||
|
if (state != State.CONNECTING && state != State.CONNECTED) {
|
||
|
return
|
||
|
}
|
||
|
|
||
|
cancelTimer()
|
||
|
androidAudioManager.stopBluetoothSco()
|
||
|
androidAudioManager.isBluetoothScoOn = false
|
||
|
state = State.DISCONNECTING
|
||
|
}
|
||
|
|
||
|
fun updateDevice() {
|
||
|
handler.assertHandlerThread()
|
||
|
|
||
|
Log.d(TAG, "updateDevice(): state: $state")
|
||
|
|
||
|
if (state == State.UNINITIALIZED || bluetoothHeadset == null) {
|
||
|
return
|
||
|
}
|
||
|
|
||
|
if (bluetoothAdapter!!.getProfileConnectionState(BluetoothProfile.HEADSET) !in arrayOf(BluetoothProfile.STATE_CONNECTED)) {
|
||
|
state = State.UNAVAILABLE
|
||
|
Log.i(TAG, "No connected bluetooth headset")
|
||
|
} else {
|
||
|
state = State.AVAILABLE
|
||
|
Log.i(TAG, "Connected bluetooth headset.")
|
||
|
}
|
||
|
}
|
||
|
|
||
|
private fun updateAudioDeviceState() {
|
||
|
audioManager.handleCommand(AudioManagerCommand.UpdateAudioDeviceState)
|
||
|
}
|
||
|
|
||
|
private fun startTimer() {
|
||
|
handler.postDelayed(bluetoothTimeout, SCO_TIMEOUT)
|
||
|
}
|
||
|
|
||
|
private fun cancelTimer() {
|
||
|
handler.removeCallbacks(bluetoothTimeout)
|
||
|
}
|
||
|
|
||
|
private fun onBluetoothTimeout() {
|
||
|
Log.i(TAG, "onBluetoothTimeout: state: $state bluetoothHeadset: $bluetoothHeadset")
|
||
|
|
||
|
if (state == State.UNINITIALIZED || bluetoothHeadset == null || state != State.CONNECTING) {
|
||
|
return
|
||
|
}
|
||
|
|
||
|
var scoConnected = false
|
||
|
|
||
|
if (audioManager.isBluetoothScoOn()) {
|
||
|
Log.d(TAG, "Connected with device")
|
||
|
scoConnected = true
|
||
|
} else {
|
||
|
Log.d(TAG, "Not connected with device")
|
||
|
}
|
||
|
|
||
|
if (scoConnected) {
|
||
|
Log.i(TAG, "Device actually connected and not timed out")
|
||
|
state = State.CONNECTED
|
||
|
scoConnectionAttempts = 0
|
||
|
} else {
|
||
|
Log.w(TAG, "Failed to connect after timeout")
|
||
|
stopScoAudio()
|
||
|
}
|
||
|
|
||
|
updateAudioDeviceState()
|
||
|
}
|
||
|
|
||
|
private fun onServiceConnected(proxy: BluetoothHeadset?) {
|
||
|
bluetoothHeadset = proxy
|
||
|
androidAudioManager.isBluetoothScoOn = true
|
||
|
updateAudioDeviceState()
|
||
|
}
|
||
|
|
||
|
private fun onServiceDisconnected() {
|
||
|
stopScoAudio()
|
||
|
bluetoothHeadset = null
|
||
|
state = State.UNAVAILABLE
|
||
|
updateAudioDeviceState()
|
||
|
}
|
||
|
|
||
|
private fun onHeadsetConnectionStateChanged(connectionState: Int) {
|
||
|
Log.i(TAG, "onHeadsetConnectionStateChanged: state: $state connectionState: ${connectionState.toStateString()}")
|
||
|
|
||
|
when (connectionState) {
|
||
|
BluetoothHeadset.STATE_CONNECTED -> {
|
||
|
scoConnectionAttempts = 0
|
||
|
updateAudioDeviceState()
|
||
|
}
|
||
|
BluetoothHeadset.STATE_DISCONNECTED -> {
|
||
|
stopScoAudio()
|
||
|
updateAudioDeviceState()
|
||
|
}
|
||
|
}
|
||
|
}
|
||
|
|
||
|
private fun onAudioStateChanged(audioState: Int, isInitialStateChange: Boolean) {
|
||
|
Log.i(TAG, "onAudioStateChanged: state: $state audioState: ${audioState.toStateString()} initialSticky: $isInitialStateChange")
|
||
|
|
||
|
if (audioState == AudioManager.SCO_AUDIO_STATE_CONNECTED) {
|
||
|
cancelTimer()
|
||
|
if (state == State.CONNECTING) {
|
||
|
Log.d(TAG, "Bluetooth audio SCO is now connected")
|
||
|
state = State.CONNECTED
|
||
|
scoConnectionAttempts = 0
|
||
|
updateAudioDeviceState()
|
||
|
} else {
|
||
|
Log.w(TAG, "Unexpected state ${audioState.toStateString()}")
|
||
|
}
|
||
|
} else if (audioState == AudioManager.SCO_AUDIO_STATE_CONNECTING) {
|
||
|
Log.d(TAG, "Bluetooth audio SCO is now connecting...")
|
||
|
} else if (audioState == AudioManager.SCO_AUDIO_STATE_DISCONNECTED) {
|
||
|
Log.d(TAG, "Bluetooth audio SCO is now disconnected")
|
||
|
if (isInitialStateChange) {
|
||
|
Log.d(TAG, "Ignore ${audioState.toStateString()} initial sticky broadcast.")
|
||
|
return
|
||
|
}
|
||
|
updateAudioDeviceState()
|
||
|
}
|
||
|
}
|
||
|
|
||
|
private inner class BluetoothServiceListener : BluetoothProfile.ServiceListener {
|
||
|
override fun onServiceConnected(profile: Int, proxy: BluetoothProfile?) {
|
||
|
if (profile == BluetoothProfile.HEADSET) {
|
||
|
handler.post {
|
||
|
if (state != State.UNINITIALIZED) {
|
||
|
onServiceConnected(proxy as? BluetoothHeadset)
|
||
|
}
|
||
|
}
|
||
|
}
|
||
|
}
|
||
|
|
||
|
override fun onServiceDisconnected(profile: Int) {
|
||
|
if (profile == BluetoothProfile.HEADSET) {
|
||
|
handler.post {
|
||
|
if (state != State.UNINITIALIZED) {
|
||
|
onServiceDisconnected()
|
||
|
}
|
||
|
}
|
||
|
}
|
||
|
}
|
||
|
}
|
||
|
|
||
|
private inner class BluetoothHeadsetBroadcastReceiver : BroadcastReceiver() {
|
||
|
override fun onReceive(context: Context, intent: Intent) {
|
||
|
if (intent.action == BluetoothHeadset.ACTION_CONNECTION_STATE_CHANGED) {
|
||
|
val connectionState: Int = intent.getIntExtra(BluetoothHeadset.EXTRA_STATE, BluetoothHeadset.STATE_DISCONNECTED)
|
||
|
handler.post {
|
||
|
if (state != State.UNINITIALIZED) {
|
||
|
onHeadsetConnectionStateChanged(connectionState)
|
||
|
}
|
||
|
}
|
||
|
} else if (intent.action == BluetoothHeadset.ACTION_AUDIO_STATE_CHANGED) {
|
||
|
// val connectionState: Int = intent.getIntExtra(BluetoothHeadset.EXTRA_STATE, BluetoothHeadset.STATE_AUDIO_DISCONNECTED)
|
||
|
// handler.post {
|
||
|
// if (state != State.UNINITIALIZED) {
|
||
|
// onAudioStateChanged(connectionState, isInitialStickyBroadcast)
|
||
|
// }
|
||
|
// }
|
||
|
} else if (intent.action == AudioManager.ACTION_SCO_AUDIO_STATE_UPDATED) {
|
||
|
val scoState: Int = intent.getIntExtra(AudioManager.EXTRA_SCO_AUDIO_STATE, AudioManager.ERROR)
|
||
|
handler.post {
|
||
|
if (state != State.UNINITIALIZED) {
|
||
|
onAudioStateChanged(scoState, isInitialStickyBroadcast)
|
||
|
}
|
||
|
}
|
||
|
}
|
||
|
}
|
||
|
}
|
||
|
|
||
|
enum class State {
|
||
|
UNINITIALIZED,
|
||
|
UNAVAILABLE,
|
||
|
AVAILABLE,
|
||
|
DISCONNECTING,
|
||
|
CONNECTING,
|
||
|
CONNECTED,
|
||
|
ERROR;
|
||
|
|
||
|
fun shouldUpdate(): Boolean {
|
||
|
return this == AVAILABLE || this == UNAVAILABLE || this == DISCONNECTING
|
||
|
}
|
||
|
|
||
|
fun hasDevice(): Boolean {
|
||
|
return this == CONNECTED || this == CONNECTING || this == AVAILABLE
|
||
|
}
|
||
|
}
|
||
|
|
||
|
companion object {
|
||
|
private val TAG = Log.tag(SignalBluetoothManager::class.java)
|
||
|
private val SCO_TIMEOUT = TimeUnit.SECONDS.toMillis(4)
|
||
|
private const val MAX_CONNECTION_ATTEMPTS = 2
|
||
|
}
|
||
|
}
|
||
|
|
||
|
private fun Int.toStateString(): String {
|
||
|
return when (this) {
|
||
|
BluetoothAdapter.STATE_DISCONNECTED -> "DISCONNECTED"
|
||
|
BluetoothAdapter.STATE_CONNECTED -> "CONNECTED"
|
||
|
BluetoothAdapter.STATE_CONNECTING -> "CONNECTING"
|
||
|
BluetoothAdapter.STATE_DISCONNECTING -> "DISCONNECTING"
|
||
|
BluetoothAdapter.STATE_OFF -> "OFF"
|
||
|
BluetoothAdapter.STATE_ON -> "ON"
|
||
|
BluetoothAdapter.STATE_TURNING_OFF -> "TURNING_OFF"
|
||
|
BluetoothAdapter.STATE_TURNING_ON -> "TURNING_ON"
|
||
|
else -> "UNKNOWN"
|
||
|
}
|
||
|
}
|
||
|
|
||
|
private fun Int.toScoString(): String = when (this) {
|
||
|
AudioManager.SCO_AUDIO_STATE_DISCONNECTED -> "DISCONNECTED"
|
||
|
AudioManager.SCO_AUDIO_STATE_CONNECTED -> "CONNECTED"
|
||
|
AudioManager.SCO_AUDIO_STATE_CONNECTING -> "CONNECTING"
|
||
|
AudioManager.SCO_AUDIO_STATE_ERROR -> "ERROR"
|
||
|
else -> "UNKNOWN"
|
||
|
}
|