Skip to content

Commit

Permalink
Use BluetoothHeadset intent actions (#70)
Browse files Browse the repository at this point in the history
* Use BluetoothHeadset bluetooth connection and audio API for broadcast receiver actions

* Fix regressions after implementing new bluetooth intent api

* Fix unit tests

* Revert device name removal from onBluetoothHeadsetStateChanged for now

* Update changelog

* Adjusted changelog wording
  • Loading branch information
John Qualls authored Sep 11, 2020
1 parent 3698792 commit 3a1cf4a
Show file tree
Hide file tree
Showing 5 changed files with 106 additions and 79 deletions.
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
Enhancements

- Upgraded Kotlin to `1.4.0`.
- Improved the Bluetooth headset connection and audio change reliability by registering the `BluetoothHeadset.ACTION_CONNECTION_STATE_CHANGED` and `BluetoothHeadset.ACTION_AUDIO_STATE_CHANGED` intent actions instead of relying on `android.bluetooth.BluetoothDevice` and `android.media.AudioManager` intent actions.
- The context provided when constructing `AudioSwitch` can now take any context. Previously the `ApplicationContext` was required.

Bug Fixes
Expand Down
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
package com.twilio.audioswitch

import android.bluetooth.BluetoothAdapter
import android.bluetooth.BluetoothDevice
import android.bluetooth.BluetoothHeadset
import android.content.Context
import android.content.Intent
import android.media.AudioManager
Expand Down Expand Up @@ -48,9 +48,10 @@ internal fun simulateBluetoothSystemIntent(
context: Context,
headsetManager: BluetoothHeadsetManager,
deviceName: String = HEADSET_NAME,
action: String = BluetoothDevice.ACTION_ACL_CONNECTED
action: String = BluetoothHeadset.ACTION_CONNECTION_STATE_CHANGED
) {
val intent = Intent(action).apply {
putExtra(BluetoothHeadset.EXTRA_STATE, BluetoothHeadset.STATE_CONNECTED)
putExtra(DEVICE_NAME, deviceName)
}
headsetManager.onReceive(context, intent)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,14 +2,18 @@ package com.twilio.audioswitch.bluetooth

import android.bluetooth.BluetoothAdapter
import android.bluetooth.BluetoothClass
import android.bluetooth.BluetoothDevice
import android.bluetooth.BluetoothHeadset
import android.bluetooth.BluetoothHeadset.ACTION_AUDIO_STATE_CHANGED
import android.bluetooth.BluetoothHeadset.ACTION_CONNECTION_STATE_CHANGED
import android.bluetooth.BluetoothHeadset.STATE_AUDIO_CONNECTED
import android.bluetooth.BluetoothHeadset.STATE_AUDIO_DISCONNECTED
import android.bluetooth.BluetoothHeadset.STATE_CONNECTED
import android.bluetooth.BluetoothHeadset.STATE_DISCONNECTED
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 android.os.Handler
import android.os.Looper
import androidx.annotation.VisibleForTesting
Expand Down Expand Up @@ -50,6 +54,7 @@ internal constructor(
if (field != value) {
field = value
logger.d(TAG, "Headset state changed to $field")
if (value == Disconnected) enableBluetoothScoJob.cancelBluetoothScoJob()
}
}

Expand Down Expand Up @@ -94,59 +99,46 @@ internal constructor(
}

override fun onReceive(context: Context, intent: Intent) {
intent.action?.let { action ->
when (action) {
BluetoothDevice.ACTION_ACL_CONNECTED -> {
intent.getHeadsetDevice()?.let { bluetoothDevice ->
logger.d(
TAG,
"Bluetooth ACL device " +
bluetoothDevice.name +
" connected")
connect()
headsetListener?.onBluetoothHeadsetStateChanged(bluetoothDevice.name)
}
}
BluetoothDevice.ACTION_ACL_DISCONNECTED -> {
intent.getHeadsetDevice()?.let { bluetoothDevice ->
logger.d(
TAG,
"Bluetooth ACL device " +
bluetoothDevice.name +
" disconnected")
disconnect()
headsetListener?.onBluetoothHeadsetStateChanged()
}
}
AudioManager.ACTION_SCO_AUDIO_STATE_UPDATED -> {
intent.getIntExtra(AudioManager.EXTRA_SCO_AUDIO_STATE, AudioManager.SCO_AUDIO_STATE_ERROR).let { state ->
when (state) {
AudioManager.SCO_AUDIO_STATE_CONNECTED -> {
logger.d(TAG, "Bluetooth SCO Audio connected")
headsetState = AudioActivated
headsetListener?.onBluetoothHeadsetStateChanged()
enableBluetoothScoJob.cancelBluetoothScoJob()
}
AudioManager.SCO_AUDIO_STATE_DISCONNECTED -> {
logger.d(TAG, "Bluetooth SCO Audio disconnected")
/*
* This block is needed to restart bluetooth SCO in the event that
* the active bluetooth headset has changed.
*/
if (hasActiveHeadsetChanged()) {
enableBluetoothScoJob.executeBluetoothScoJob()
}

headsetListener?.onBluetoothHeadsetStateChanged()
disableBluetoothScoJob.cancelBluetoothScoJob()
}
AudioManager.SCO_AUDIO_STATE_ERROR -> {
logger.e(TAG, "Error retrieving Bluetooth SCO Audio state")
if (isCorrectIntentAction(intent.action)) {
intent.getHeadsetDevice()?.let { bluetoothDevice ->
intent.getIntExtra(BluetoothHeadset.EXTRA_STATE, STATE_DISCONNECTED).let { state ->
when (state) {
STATE_CONNECTED -> {
logger.d(
TAG,
"Bluetooth headset $bluetoothDevice connected")
connect()
headsetListener?.onBluetoothHeadsetStateChanged(bluetoothDevice.name)
}
STATE_DISCONNECTED -> {
logger.d(
TAG,
"Bluetooth headset $bluetoothDevice disconnected")
disconnect()
headsetListener?.onBluetoothHeadsetStateChanged()
}
STATE_AUDIO_CONNECTED -> {
logger.d(TAG, "Bluetooth audio connected on device $bluetoothDevice")
enableBluetoothScoJob.cancelBluetoothScoJob()
headsetState = AudioActivated
headsetListener?.onBluetoothHeadsetStateChanged()
}
STATE_AUDIO_DISCONNECTED -> {
logger.d(TAG, "Bluetooth audio disconnected on device $bluetoothDevice")
disableBluetoothScoJob.cancelBluetoothScoJob()
/*
* This block is needed to restart bluetooth SCO in the event that
* the active bluetooth headset has changed.
*/
if (hasActiveHeadsetChanged()) {
enableBluetoothScoJob.executeBluetoothScoJob()
}

headsetListener?.onBluetoothHeadsetStateChanged()
}
else -> {}
}
}
else -> {}
}
}
}
Expand All @@ -160,12 +152,9 @@ internal constructor(
BluetoothProfile.HEADSET)

context.registerReceiver(
this, IntentFilter(BluetoothDevice.ACTION_ACL_CONNECTED))
this, IntentFilter(ACTION_CONNECTION_STATE_CHANGED))
context.registerReceiver(
this, IntentFilter(BluetoothDevice.ACTION_ACL_DISCONNECTED))
context.registerReceiver(
this,
IntentFilter(AudioManager.ACTION_SCO_AUDIO_STATE_UPDATED))
this, IntentFilter(ACTION_AUDIO_STATE_CHANGED))
}

fun stop() {
Expand Down Expand Up @@ -198,10 +187,11 @@ internal constructor(
?: "Bluetooth")
} else null

private fun isCorrectIntentAction(intentAction: String?) =
intentAction == ACTION_CONNECTION_STATE_CHANGED || intentAction == ACTION_AUDIO_STATE_CHANGED

private fun connect() {
if (headsetState != AudioActivating) {
headsetState = Connected
}
if (!hasActiveHeadset()) headsetState = Connected
}

private fun disconnect() {
Expand Down
7 changes: 5 additions & 2 deletions audioswitch/src/test/java/com/twilio/audioswitch/TestUtil.kt
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
package com.twilio.audioswitch

import android.bluetooth.BluetoothDevice
import android.bluetooth.BluetoothHeadset
import android.bluetooth.BluetoothProfile
import android.content.Intent
import android.media.AudioManager
Expand Down Expand Up @@ -47,7 +48,7 @@ internal fun BaseTest.assertBluetoothHeadsetSetup() {
headsetManager,
BluetoothProfile.HEADSET
)
verify(context, times(3)).registerReceiver(eq(headsetManager), isA())
verify(context, times(2)).registerReceiver(eq(headsetManager), isA())
}

internal fun BaseTest.assertBluetoothHeadsetTeardown() {
Expand All @@ -60,7 +61,9 @@ internal fun BaseTest.simulateNewBluetoothHeadsetConnection(
bluetoothDevice: BluetoothDevice = expectedBluetoothDevice
) {
val intent = mock<Intent> {
whenever(mock.action).thenReturn(BluetoothDevice.ACTION_ACL_CONNECTED)
whenever(mock.action).thenReturn(BluetoothHeadset.ACTION_AUDIO_STATE_CHANGED)
whenever(mock.getIntExtra(BluetoothHeadset.EXTRA_STATE, BluetoothHeadset.STATE_DISCONNECTED))
.thenReturn(BluetoothHeadset.STATE_CONNECTED)
whenever(mock.getParcelableExtra<BluetoothDevice>(BluetoothDevice.EXTRA_DEVICE))
.thenReturn(bluetoothDevice)
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,6 @@ import android.bluetooth.BluetoothClass
import android.bluetooth.BluetoothDevice
import android.bluetooth.BluetoothHeadset
import android.content.Intent
import android.media.AudioManager
import android.os.Handler
import com.nhaarman.mockitokotlin2.any
import com.nhaarman.mockitokotlin2.isA
Expand Down Expand Up @@ -191,30 +190,38 @@ class BluetoothHeadsetManagerTest : BaseTest() {

@Parameters(method = "parameters")
@Test
fun `onReceive should register a new device when an ACL connected event is received`(
fun `onReceive should register a new device when a headset connection event is received`(
deviceClass: BluetoothClass?,
isNewDeviceConnected: Boolean
) {
whenever(expectedBluetoothDevice.bluetoothClass).thenReturn(deviceClass)
simulateNewBluetoothHeadsetConnection(expectedBluetoothDevice)
val intent = mock<Intent> {
whenever(mock.action).thenReturn(BluetoothHeadset.ACTION_AUDIO_STATE_CHANGED)
whenever(mock.getIntExtra(BluetoothHeadset.EXTRA_STATE, BluetoothHeadset.STATE_DISCONNECTED))
.thenReturn(BluetoothHeadset.STATE_CONNECTED)
whenever(mock.getParcelableExtra<BluetoothDevice>(BluetoothDevice.EXTRA_DEVICE))
.thenReturn(expectedBluetoothDevice)
}
headsetManager.onReceive(context, intent)

val invocationCount = if (isNewDeviceConnected) 1 else 0
verify(headsetListener, times(invocationCount)).onBluetoothHeadsetStateChanged(DEVICE_NAME)
}

@Parameters(method = "parameters")
@Test
fun `onReceive should disconnect a device when an ACL disconnected event is received`(
fun `onReceive should disconnect a device when a headset disconnection event is received`(
deviceClass: BluetoothClass?,
isDeviceDisconnected: Boolean
) {
whenever(expectedBluetoothDevice.bluetoothClass).thenReturn(deviceClass)
val intent = mock<Intent> {
whenever(mock.action).thenReturn(BluetoothDevice.ACTION_ACL_DISCONNECTED)
whenever(mock.action).thenReturn(BluetoothHeadset.ACTION_AUDIO_STATE_CHANGED)
whenever(mock.getIntExtra(BluetoothHeadset.EXTRA_STATE, BluetoothHeadset.STATE_DISCONNECTED))
.thenReturn(BluetoothHeadset.STATE_DISCONNECTED)
whenever(mock.getParcelableExtra<BluetoothDevice>(BluetoothDevice.EXTRA_DEVICE))
.thenReturn(expectedBluetoothDevice)
}

headsetManager.onReceive(context, intent)

val invocationCount = if (isDeviceDisconnected) 1 else 0
Expand Down Expand Up @@ -272,27 +279,32 @@ class BluetoothHeadsetManagerTest : BaseTest() {
}

@Test
fun `SCO_AUDIO_STATE_CONNECTED should cancel a running enableBluetoothScoJob`() {
headsetManager.headsetState = Connected
fun `a headset audio connection should cancel a running enableBluetoothScoJob`() {
setupConnectedState()
headsetManager.activate()
val intent = mock<Intent> {
whenever(mock.action).thenReturn(AudioManager.ACTION_SCO_AUDIO_STATE_UPDATED)
whenever(mock.getIntExtra(AudioManager.EXTRA_SCO_AUDIO_STATE, AudioManager.SCO_AUDIO_STATE_ERROR))
.thenReturn(AudioManager.SCO_AUDIO_STATE_CONNECTED)
whenever(mock.action).thenReturn(BluetoothHeadset.ACTION_AUDIO_STATE_CHANGED)
whenever(mock.getIntExtra(BluetoothHeadset.EXTRA_STATE, BluetoothHeadset.STATE_DISCONNECTED))
.thenReturn(BluetoothHeadset.STATE_AUDIO_CONNECTED)
whenever(mock.getParcelableExtra<BluetoothDevice>(BluetoothDevice.EXTRA_DEVICE))
.thenReturn(expectedBluetoothDevice)
}
headsetManager.onReceive(mock(), intent)
headsetManager.onReceive(context, intent)

assertScoJobIsCanceled(handler, headsetManager.enableBluetoothScoJob)
}

@Test
fun `SCO_AUDIO_STATE_DISCONNECTED should cancel a running disableBluetoothScoJob`() {
fun `a bluetooth headset audio disconnection should cancel a running disableBluetoothScoJob`() {
headsetManager.headsetState = AudioActivated
headsetManager.deactivate()
val intent = mock<Intent> {
whenever(mock.action).thenReturn(AudioManager.ACTION_SCO_AUDIO_STATE_UPDATED)
whenever(mock.getIntExtra(AudioManager.EXTRA_SCO_AUDIO_STATE, AudioManager.SCO_AUDIO_STATE_ERROR))
.thenReturn(AudioManager.SCO_AUDIO_STATE_DISCONNECTED)
whenever(mock.action).thenReturn(BluetoothHeadset.ACTION_AUDIO_STATE_CHANGED)
whenever(mock.getIntExtra(BluetoothHeadset.EXTRA_STATE,
BluetoothHeadset.STATE_DISCONNECTED))
.thenReturn(BluetoothHeadset.STATE_AUDIO_DISCONNECTED)
whenever(mock.getParcelableExtra<BluetoothDevice>(BluetoothDevice.EXTRA_DEVICE))
.thenReturn(expectedBluetoothDevice)
}
headsetManager.onReceive(mock(), intent)

Expand Down Expand Up @@ -397,6 +409,26 @@ class BluetoothHeadsetManagerTest : BaseTest() {
verifyZeroInteractions(handler)
}

@Test
fun `it should cancel the enable bluetooth sco job when setting the state to disconnected`() {
val bluetoothProfile = mock<BluetoothHeadset> {
whenever(mock.connectedDevices).thenReturn(bluetoothDevices, bluetoothDevices, emptyList())
}
headsetManager.onServiceConnected(0, bluetoothProfile)
headsetManager.activate()

val intent = mock<Intent> {
whenever(mock.action).thenReturn(BluetoothHeadset.ACTION_AUDIO_STATE_CHANGED)
whenever(mock.getIntExtra(BluetoothHeadset.EXTRA_STATE, BluetoothHeadset.STATE_DISCONNECTED))
.thenReturn(BluetoothHeadset.STATE_DISCONNECTED)
whenever(mock.getParcelableExtra<BluetoothDevice>(BluetoothDevice.EXTRA_DEVICE))
.thenReturn(expectedBluetoothDevice)
}
headsetManager.onReceive(context, intent)

assertScoJobIsCanceled(handler, headsetManager.enableBluetoothScoJob)
}

private fun setupHandlerMock() =
mock<Handler> {
whenever(mock.post(any())).thenAnswer {
Expand Down

0 comments on commit 3a1cf4a

Please sign in to comment.