Skip to content

Commit

Permalink
VBLOCKS-2801 bluetooth listener (#159)
Browse files Browse the repository at this point in the history
* BluetoothHeadsetConnectionListener can now be added to monitor bluetooth state

* Update CHANGELOG.md and README.md

* Update tests

* Spotless check

* Listen to Bluetooth sco state changes and add onBluetoothScoStateChanged

* Update tests
  • Loading branch information
ocarevs authored Apr 24, 2024
1 parent fbf5ecd commit d43c7b3
Show file tree
Hide file tree
Showing 13 changed files with 148 additions and 15 deletions.
6 changes: 6 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,12 @@ Enhancements
- Updated gradle plugin to 8.3.1


### 1.1.10 (March 21, 2024)

Enhancements

- BluetoothHeadsetConnectionListener now can be added to AudioSwitch to notify when bluetooth device has connected or failed to connect.

### 1.1.9 (July 13, 2023)

Enhancements
Expand Down
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -111,6 +111,7 @@ Multiple connected bluetooth headsets are supported.
- The library will accurately display the up to date active bluetooth headset within the `AudioSwitch` `availableAudioDevices` and `selectedAudioDevice` functions.
- Other connected headsets are not stored by the library at this moment.
- In the event of a failure to connecting audio to a bluetooth headset, the library will revert the selected audio device (this is usually the Earpiece on a phone).
- Additionally [BluetoothHeadsetConnectionListener](audioswitch/src/main/java/com/twilio/audioswitch/bluetooth/BluetoothHeadsetConnectionListener.kt) can be provided to AudioSwitch constructor to monitor state changes and connection failures.
- If a user would like to switch between multiple Bluetooth headsets, then they need to switch the active bluetooth headset from the system Bluetooth settings.
- The newly activated headset will be propagated to the `AudioSwitch` `availableAudioDevices` and `selectedAudioDevice` functions.

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -87,7 +87,7 @@ class AudioSwitchIntegrationTest : AndroidTestBase() {
}
}
InstrumentationRegistry.getInstrumentation().runOnMainSync {
val audioSwitch = AudioSwitch(getTargetContext(), true, audioFocusChangeListener)
val audioSwitch = AudioSwitch(getTargetContext(), null, true, audioFocusChangeListener)
audioSwitch.start { _, _ -> }
audioSwitch.activate()
}
Expand Down Expand Up @@ -117,7 +117,7 @@ class AudioSwitchIntegrationTest : AndroidTestBase() {
val audioFocusUtil = AudioFocusUtil(audioManager, audioFocusChangeListener)
audioFocusUtil.requestFocus()

val audioSwitch = AudioSwitch(getTargetContext(), true)
val audioSwitch = AudioSwitch(getTargetContext(), null, true)
InstrumentationRegistry.getInstrumentation().runOnMainSync {
audioSwitch.start { _, _ -> }
audioSwitch.activate()
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -6,10 +6,14 @@ import androidx.test.filters.LargeTest
import com.twilio.audioswitch.AudioDevice.Earpiece
import com.twilio.audioswitch.AudioDevice.Speakerphone
import com.twilio.audioswitch.AudioDevice.WiredHeadset
import com.twilio.audioswitch.bluetooth.BluetoothHeadsetConnectionListener
import junit.framework.TestCase.assertTrue
import org.hamcrest.CoreMatchers.equalTo
import org.hamcrest.MatcherAssert.assertThat
import org.junit.Test
import org.junit.runner.RunWith
import java.util.concurrent.CountDownLatch
import java.util.concurrent.TimeUnit

@RunWith(AndroidJUnit4::class)
@LargeTest
Expand All @@ -29,6 +33,31 @@ class AutomaticDeviceSelectionTest : AndroidTestBase() {
audioSwitch.stop()
}

@UiThreadTest
@Test
fun `it_should_notify_callback_when_bluetooth_connects`() {
val context = getInstrumentationContext()
val bluetoothConnectedLatch = CountDownLatch(1)
val bluetoothListener = object : BluetoothHeadsetConnectionListener {
override fun onBluetoothHeadsetStateChanged(headsetName: String?, state: Int) {
bluetoothConnectedLatch.countDown()
}

override fun onBluetoothScoStateChanged(state: Int) {}

override fun onBluetoothHeadsetActivationError() {}
}
val (audioSwitch, bluetoothHeadsetReceiver, wiredHeadsetReceiver) = setupFakeAudioSwitch(context, bluetoothListener = bluetoothListener)

audioSwitch.start { _, _ -> }
simulateBluetoothSystemIntent(context, bluetoothHeadsetReceiver)
simulateWiredHeadsetSystemIntent(context, wiredHeadsetReceiver)

assertThat(audioSwitch.selectedAudioDevice!! is AudioDevice.BluetoothHeadset, equalTo(true))
assertTrue(bluetoothConnectedLatch.await(5, TimeUnit.SECONDS))
audioSwitch.stop()
}

@UiThreadTest
@Test
fun `it_should_select_the_wired_headset_by_default`() {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ import com.twilio.audioswitch.android.DEVICE_NAME
import com.twilio.audioswitch.android.FakeBluetoothIntentProcessor
import com.twilio.audioswitch.android.HEADSET_NAME
import com.twilio.audioswitch.android.ProductionLogger
import com.twilio.audioswitch.bluetooth.BluetoothHeadsetConnectionListener
import com.twilio.audioswitch.bluetooth.BluetoothHeadsetManager
import com.twilio.audioswitch.wired.INTENT_STATE
import com.twilio.audioswitch.wired.STATE_PLUGGED
Expand All @@ -29,6 +30,7 @@ internal fun setupFakeAudioSwitch(
Earpiece::class.java,
Speakerphone::class.java,
),
bluetoothListener: BluetoothHeadsetConnectionListener? = null,
): Triple<AudioSwitch, BluetoothHeadsetManager, WiredHeadsetReceiver> {
val audioManager = context.getSystemService(Context.AUDIO_SERVICE) as AudioManager
val logger = ProductionLogger(true)
Expand Down Expand Up @@ -56,6 +58,7 @@ internal fun setupFakeAudioSwitch(
return Triple(
AudioSwitch(
context,
bluetoothListener,
logger,
{},
preferredDevicesList,
Expand Down
17 changes: 16 additions & 1 deletion audioswitch/src/main/java/com/twilio/audioswitch/AudioSwitch.kt
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,8 @@ private const val TAG = "AudioSwitch"
* accessed from a single application thread. Accessing an instance from multiple threads may cause
* synchronization problems.
*
* @property bluetoothHeadsetConnectionListener Listener to notify if Bluetooth device state has
* changed (connect, disconnect, audio connect, audio disconnect) or failed to connect. Null by default.
* @property loggingEnabled A property to configure AudioSwitch logging behavior. AudioSwitch logging is disabled by
* default.
* @property selectedAudioDevice Retrieves the selected [AudioDevice] from [AudioSwitch.selectDevice].
Expand All @@ -44,20 +46,27 @@ class AudioSwitch {
private val mutableAudioDevices = ArrayList<AudioDevice>()
private var bluetoothHeadsetManager: BluetoothHeadsetManager? = null
private val preferredDeviceList: List<Class<out AudioDevice>>
private var bluetoothHeadsetConnectionListener: BluetoothHeadsetConnectionListener? = null

internal var state: State = STOPPED
internal enum class State {
STARTED, ACTIVATED, STOPPED
}

internal val bluetoothDeviceConnectionListener = object : BluetoothHeadsetConnectionListener {
override fun onBluetoothHeadsetStateChanged(headsetName: String?) {
override fun onBluetoothHeadsetStateChanged(headsetName: String?, state: Int) {
enumerateDevices(headsetName)
bluetoothHeadsetConnectionListener?.onBluetoothHeadsetStateChanged(headsetName, state)
}

override fun onBluetoothScoStateChanged(state: Int) {
bluetoothHeadsetConnectionListener?.onBluetoothScoStateChanged(state)
}

override fun onBluetoothHeadsetActivationError() {
if (userSelectedDevice is BluetoothHeadset) userSelectedDevice = null
enumerateDevices()
bluetoothHeadsetConnectionListener?.onBluetoothHeadsetActivationError()
}
}

Expand Down Expand Up @@ -85,6 +94,8 @@ class AudioSwitch {
/**
* Constructs a new AudioSwitch instance.
* - [context] - An Android Context.
* - [bluetoothHeadsetConnectionListener] - A listener to notify if Bluetooth device state has
* changed (connect, disconnect, audio connect, audio disconnect) or failed to connect. Null by default
* - [loggingEnabled] - Toggle whether logging is enabled. This argument is false by default.
* - [audioFocusChangeListener] - A listener that is invoked when the system audio focus is updated.
* Note that updates are only sent to the listener after [activate] has been called.
Expand All @@ -101,11 +112,13 @@ class AudioSwitch {
@JvmOverloads
constructor(
context: Context,
bluetoothHeadsetConnectionListener: BluetoothHeadsetConnectionListener? = null,
loggingEnabled: Boolean = false,
audioFocusChangeListener: OnAudioFocusChangeListener = OnAudioFocusChangeListener {},
preferredDeviceList: List<Class<out AudioDevice>> = defaultPreferredDeviceList,
) : this(
context.applicationContext,
bluetoothHeadsetConnectionListener,
ProductionLogger(loggingEnabled),
audioFocusChangeListener,
preferredDeviceList,
Expand All @@ -114,6 +127,7 @@ class AudioSwitch {
@VisibleForTesting(otherwise = VisibleForTesting.PRIVATE)
internal constructor(
context: Context,
bluetoothHeadsetConnectionListener: BluetoothHeadsetConnectionListener?,
logger: Logger,
audioFocusChangeListener: OnAudioFocusChangeListener,
preferredDeviceList: List<Class<out AudioDevice>>,
Expand All @@ -132,6 +146,7 @@ class AudioSwitch {
),
) {
this.logger = logger
this.bluetoothHeadsetConnectionListener = bluetoothHeadsetConnectionListener
this.audioDeviceManager = audioDeviceManager
this.wiredHeadsetReceiver = wiredHeadsetReceiver
this.bluetoothHeadsetManager = headsetManager
Expand Down
Original file line number Diff line number Diff line change
@@ -1,6 +1,26 @@
package com.twilio.audioswitch.bluetooth

internal interface BluetoothHeadsetConnectionListener {
fun onBluetoothHeadsetStateChanged(headsetName: String? = null)
import android.bluetooth.BluetoothHeadset
import android.media.AudioManager

/**
* Notifies if Bluetooth device state has changed (connect, disconnect, audio connect, audio disconnect) or failed to connect.
*/
interface BluetoothHeadsetConnectionListener {

/**
* @param headsetName name of the headset
* @param state provided by [BluetoothHeadset]
*/
fun onBluetoothHeadsetStateChanged(headsetName: String? = null, state: Int = 0)

/**
* @param state provided by [AudioManager]
*/
fun onBluetoothScoStateChanged(state: Int = 0)

/**
* Triggered when Bluetooth SCO job has timed out.
*/
fun onBluetoothHeadsetActivationError()
}
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,11 @@ import android.content.Context
import android.content.Intent
import android.content.IntentFilter
import android.content.pm.PackageManager.PERMISSION_GRANTED
import android.media.AudioManager
import android.media.AudioManager.ACTION_SCO_AUDIO_STATE_UPDATED
import android.media.AudioManager.SCO_AUDIO_STATE_CONNECTED
import android.media.AudioManager.SCO_AUDIO_STATE_CONNECTING
import android.media.AudioManager.SCO_AUDIO_STATE_DISCONNECTED
import android.os.Handler
import android.os.Looper
import androidx.annotation.VisibleForTesting
Expand Down Expand Up @@ -123,21 +128,21 @@ internal constructor(
"Bluetooth headset $bluetoothDevice connected",
)
connect()
headsetListener?.onBluetoothHeadsetStateChanged(bluetoothDevice.name)
headsetListener?.onBluetoothHeadsetStateChanged(bluetoothDevice.name, STATE_CONNECTED)
}
STATE_DISCONNECTED -> {
logger.d(
TAG,
"Bluetooth headset $bluetoothDevice disconnected",
)
disconnect()
headsetListener?.onBluetoothHeadsetStateChanged()
headsetListener?.onBluetoothHeadsetStateChanged(bluetoothDevice.name, STATE_DISCONNECTED)
}
STATE_AUDIO_CONNECTED -> {
logger.d(TAG, "Bluetooth audio connected on device $bluetoothDevice")
enableBluetoothScoJob.cancelBluetoothScoJob()
headsetState = AudioActivated
headsetListener?.onBluetoothHeadsetStateChanged()
headsetListener?.onBluetoothHeadsetStateChanged(bluetoothDevice.name, STATE_AUDIO_CONNECTED)
}
STATE_AUDIO_DISCONNECTED -> {
logger.d(TAG, "Bluetooth audio disconnected on device $bluetoothDevice")
Expand All @@ -150,12 +155,47 @@ internal constructor(
enableBluetoothScoJob.executeBluetoothScoJob()
}

headsetListener?.onBluetoothHeadsetStateChanged()
headsetListener?.onBluetoothHeadsetStateChanged(bluetoothDevice.name, STATE_AUDIO_DISCONNECTED)
}
else -> {}
}
}
}
intent.getIntExtra(AudioManager.EXTRA_SCO_AUDIO_STATE, SCO_AUDIO_STATE_DISCONNECTED).let { state ->
when (state) {
SCO_AUDIO_STATE_CONNECTING -> {
logger.d(
TAG,
"Bluetooth SCO connecting",
)

headsetListener?.onBluetoothScoStateChanged(
SCO_AUDIO_STATE_CONNECTING,
)
}
SCO_AUDIO_STATE_CONNECTED -> {
logger.d(
TAG,
"Bluetooth SCO connected",
)

headsetListener?.onBluetoothScoStateChanged(
SCO_AUDIO_STATE_CONNECTED,
)
}
SCO_AUDIO_STATE_DISCONNECTED -> {
logger.d(
TAG,
"Bluetooth SCO disconnected",
)

headsetListener?.onBluetoothScoStateChanged(
SCO_AUDIO_STATE_DISCONNECTED,
)
}
else -> {}
}
}
}
}

Expand All @@ -177,6 +217,10 @@ internal constructor(
this,
IntentFilter(ACTION_AUDIO_STATE_CHANGED),
)
context.registerReceiver(
this,
IntentFilter(ACTION_SCO_AUDIO_STATE_UPDATED),
)
hasRegisteredReceivers = true
}
} else {
Expand Down Expand Up @@ -243,7 +287,7 @@ internal constructor(
}

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

private fun connect() {
if (!hasActiveHeadset()) headsetState = Connected
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,7 @@ public void setUp() {
javaAudioSwitch =
new AudioSwitch(
getContext$audioswitch_debug(),
null,
new UnitTestLogger(false),
getDefaultAudioFocusChangeListener$audioswitch_debug(),
getPreferredDeviceList$audioswitch_debug(),
Expand Down Expand Up @@ -130,6 +131,7 @@ public void shouldAllowChangingThePreferredDeviceList() {
javaAudioSwitch =
new AudioSwitch(
getContext$audioswitch_debug(),
getBluetoothListener$audioswitch_debug(),
getLogger$audioswitch_debug(),
getDefaultAudioFocusChangeListener$audioswitch_debug(),
preferredDeviceList,
Expand Down
Loading

0 comments on commit d43c7b3

Please sign in to comment.