Skip to content

Commit

Permalink
VBLOCKS-2802 Bluetooth permissions (#162)
Browse files Browse the repository at this point in the history
* Remove bluetooth permissions

* Update tests

* Add supresslint

* Update to use PermissionCheckStrategy, update unit tests

* Add bluetooth permission in test manifest

* TestUtil add missing headsetManager

* Update README.md
  • Loading branch information
ocarevs authored May 21, 2024
1 parent d43c7b3 commit cd8794a
Show file tree
Hide file tree
Showing 12 changed files with 112 additions and 114 deletions.
10 changes: 3 additions & 7 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,17 +1,13 @@
# Changelog
### 1.1.10 (In Progress)
### 1.1.10 (In progress)

Enhancements

- Updated gradle version to 8.4
- 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.
- BLUETOOTH_CONNECT and/or BLUETOOTH permission have been removed and are optional now. If not provided bluetooth device
will not appear in the list of available devices and no callbacks will be received for BluetoothHeadsetConnectionListener.

### 1.1.9 (July 13, 2023)

Expand Down
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -108,6 +108,7 @@ audioSwitch.deactivate()
## Bluetooth Support

Multiple connected bluetooth headsets are supported.
- Bluetooth support requires BLUETOOTH_CONNECT or BLUETOOTH permission. These permission have to be added to the application using AudioSwitch, they do not come with the library.
- 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).
Expand Down
5 changes: 4 additions & 1 deletion audioswitch/src/androidTest/AndroidManifest.xml
Original file line number Diff line number Diff line change
Expand Up @@ -6,4 +6,7 @@
<uses-permission android:name="android.permission.BLUETOOTH_SCAN"
android:usesPermissionFlags="neverForLocation" />
<uses-permission android:name="android.permission.BLUETOOTH_ADVERTISE" />
</manifest>
<uses-permission android:name="android.permission.BLUETOOTH" android:maxSdkVersion="30"/>
<uses-permission android:name="android.permission.BLUETOOTH_CONNECT" />

</manifest>
Original file line number Diff line number Diff line change
Expand Up @@ -64,7 +64,7 @@ internal fun setupFakeAudioSwitch(
preferredDevicesList,
audioDeviceManager,
wiredHeadsetReceiver,
headsetManager,
bluetoothHeadsetManager = headsetManager,
),
headsetManager!!,
wiredHeadsetReceiver,
Expand Down
2 changes: 0 additions & 2 deletions audioswitch/src/main/AndroidManifest.xml
Original file line number Diff line number Diff line change
@@ -1,7 +1,5 @@
<manifest xmlns:android="http://schemas.android.com/apk/res/android">

<uses-permission android:name="android.permission.BLUETOOTH" android:maxSdkVersion="30"/>
<uses-permission android:name="android.permission.BLUETOOTH_CONNECT" />
<uses-permission android:name="android.permission.MODIFY_AUDIO_SETTINGS" />

</manifest>
44 changes: 41 additions & 3 deletions audioswitch/src/main/java/com/twilio/audioswitch/AudioSwitch.kt
Original file line number Diff line number Diff line change
@@ -1,7 +1,10 @@
package com.twilio.audioswitch

import android.Manifest
import android.annotation.SuppressLint
import android.bluetooth.BluetoothAdapter
import android.content.Context
import android.content.pm.PackageManager.PERMISSION_GRANTED
import android.media.AudioManager
import android.media.AudioManager.OnAudioFocusChangeListener
import androidx.annotation.VisibleForTesting
Expand All @@ -13,21 +16,23 @@ import com.twilio.audioswitch.AudioSwitch.State.ACTIVATED
import com.twilio.audioswitch.AudioSwitch.State.STARTED
import com.twilio.audioswitch.AudioSwitch.State.STOPPED
import com.twilio.audioswitch.android.Logger
import com.twilio.audioswitch.android.PermissionsCheckStrategy
import com.twilio.audioswitch.android.ProductionLogger
import com.twilio.audioswitch.bluetooth.BluetoothHeadsetConnectionListener
import com.twilio.audioswitch.bluetooth.BluetoothHeadsetManager
import com.twilio.audioswitch.wired.WiredDeviceConnectionListener
import com.twilio.audioswitch.wired.WiredHeadsetReceiver

private const val TAG = "AudioSwitch"
private const val PERMISSION_ERROR_MESSAGE = "Bluetooth unsupported, permissions not granted"

/**
* This class enables developers to enumerate available audio devices and select which device audio
* should be routed to. It is strongly recommended that instances of this class are created and
* 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
* @property bluetoothHeadsetConnectionListener Requires bluetooth permission. 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.
Expand All @@ -47,6 +52,7 @@ class AudioSwitch {
private var bluetoothHeadsetManager: BluetoothHeadsetManager? = null
private val preferredDeviceList: List<Class<out AudioDevice>>
private var bluetoothHeadsetConnectionListener: BluetoothHeadsetConnectionListener? = null
private val permissionsRequestStrategy: PermissionsCheckStrategy

internal var state: State = STOPPED
internal enum class State {
Expand Down Expand Up @@ -138,7 +144,8 @@ class AudioSwitch {
audioFocusChangeListener = audioFocusChangeListener,
),
wiredHeadsetReceiver: WiredHeadsetReceiver = WiredHeadsetReceiver(context, logger),
headsetManager: BluetoothHeadsetManager? = BluetoothHeadsetManager.newInstance(
permissionsCheckStrategy: PermissionsCheckStrategy = DefaultPermissionsCheckStrategy(context),
bluetoothHeadsetManager: BluetoothHeadsetManager? = BluetoothHeadsetManager.newInstance(
context,
logger,
BluetoothAdapter.getDefaultAdapter(),
Expand All @@ -149,8 +156,14 @@ class AudioSwitch {
this.bluetoothHeadsetConnectionListener = bluetoothHeadsetConnectionListener
this.audioDeviceManager = audioDeviceManager
this.wiredHeadsetReceiver = wiredHeadsetReceiver
this.bluetoothHeadsetManager = headsetManager
this.preferredDeviceList = getPreferredDeviceList(preferredDeviceList)
this.permissionsRequestStrategy = permissionsCheckStrategy
this.bluetoothHeadsetManager = if (hasPermissions()) {
bluetoothHeadsetManager
} else {
logger.w(TAG, PERMISSION_ERROR_MESSAGE)
null
}
logger.d(TAG, "AudioSwitch($VERSION)")
logger.d(TAG, "Preferred device list = ${this.preferredDeviceList.map { it.simpleName }}")
}
Expand Down Expand Up @@ -387,6 +400,31 @@ class AudioSwitch {
audioDeviceChangeListener = null
}

internal fun hasPermissions() = permissionsRequestStrategy.hasPermissions()

internal class DefaultPermissionsCheckStrategy(private val context: Context) : PermissionsCheckStrategy {

@SuppressLint("NewApi")
override fun hasPermissions(): Boolean {
return if (context.applicationInfo.targetSdkVersion <= android.os.Build.VERSION_CODES.R ||
android.os.Build.VERSION.SDK_INT <= android.os.Build.VERSION_CODES.R
) {
PERMISSION_GRANTED == context.checkPermission(
Manifest.permission.BLUETOOTH,
android.os.Process.myPid(),
android.os.Process.myUid(),
)
} else {
// for android 12/S or newer
PERMISSION_GRANTED == context.checkPermission(
Manifest.permission.BLUETOOTH_CONNECT,
android.os.Process.myPid(),
android.os.Process.myUid(),
)
}
}
}

companion object {
/**
* The version of the AudioSwitch library.
Expand Down
Original file line number Diff line number Diff line change
@@ -1,9 +1,11 @@
package com.twilio.audioswitch.android

import android.annotation.SuppressLint
import android.bluetooth.BluetoothDevice

internal const val DEFAULT_DEVICE_NAME = "Bluetooth"

@SuppressLint("MissingPermission")
internal data class BluetoothDeviceWrapperImpl(
val device: BluetoothDevice,
override val name: String = device.name ?: DEFAULT_DEVICE_NAME,
Expand Down
Original file line number Diff line number Diff line change
@@ -1,6 +1,5 @@
package com.twilio.audioswitch.bluetooth

import android.Manifest
import android.annotation.SuppressLint
import android.bluetooth.BluetoothAdapter
import android.bluetooth.BluetoothClass
Expand All @@ -16,7 +15,6 @@ import android.content.BroadcastReceiver
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
Expand All @@ -31,7 +29,6 @@ import com.twilio.audioswitch.android.BluetoothDeviceWrapper
import com.twilio.audioswitch.android.BluetoothIntentProcessor
import com.twilio.audioswitch.android.BluetoothIntentProcessorImpl
import com.twilio.audioswitch.android.Logger
import com.twilio.audioswitch.android.PermissionsCheckStrategy
import com.twilio.audioswitch.android.SystemClockWrapper
import com.twilio.audioswitch.bluetooth.BluetoothHeadsetManager.HeadsetState.AudioActivated
import com.twilio.audioswitch.bluetooth.BluetoothHeadsetManager.HeadsetState.AudioActivating
Expand All @@ -40,7 +37,6 @@ import com.twilio.audioswitch.bluetooth.BluetoothHeadsetManager.HeadsetState.Con
import com.twilio.audioswitch.bluetooth.BluetoothHeadsetManager.HeadsetState.Disconnected

private const val TAG = "BluetoothHeadsetManager"
private const val PERMISSION_ERROR_MESSAGE = "Bluetooth unsupported, permissions not granted"

internal class BluetoothHeadsetManager
@VisibleForTesting(otherwise = VisibleForTesting.PRIVATE)
Expand All @@ -54,7 +50,6 @@ internal constructor(
systemClockWrapper: SystemClockWrapper = SystemClockWrapper(),
private val bluetoothIntentProcessor: BluetoothIntentProcessor = BluetoothIntentProcessorImpl(),
private var headsetProxy: BluetoothHeadset? = null,
private val permissionsRequestStrategy: PermissionsCheckStrategy = DefaultPermissionsCheckStrategy(context),
private var hasRegisteredReceivers: Boolean = false,
) : BluetoothProfile.ServiceListener, BroadcastReceiver() {

Expand Down Expand Up @@ -100,6 +95,7 @@ internal constructor(
}
}

@SuppressLint("MissingPermission")
override fun onServiceConnected(profile: Int, bluetoothProfile: BluetoothProfile) {
headsetProxy = bluetoothProfile as BluetoothHeadset
bluetoothProfile.connectedDevices.forEach { device ->
Expand Down Expand Up @@ -200,56 +196,44 @@ internal constructor(
}

fun start(headsetListener: BluetoothHeadsetConnectionListener) {
if (hasPermissions()) {
this.headsetListener = headsetListener

bluetoothAdapter.getProfileProxy(
context,
this.headsetListener = headsetListener

bluetoothAdapter.getProfileProxy(
context,
this,
BluetoothProfile.HEADSET,
)
if (!hasRegisteredReceivers) {
context.registerReceiver(
this,
BluetoothProfile.HEADSET,
IntentFilter(ACTION_CONNECTION_STATE_CHANGED),
)
if (!hasRegisteredReceivers) {
context.registerReceiver(
this,
IntentFilter(ACTION_CONNECTION_STATE_CHANGED),
)
context.registerReceiver(
this,
IntentFilter(ACTION_AUDIO_STATE_CHANGED),
)
context.registerReceiver(
this,
IntentFilter(ACTION_SCO_AUDIO_STATE_UPDATED),
)
hasRegisteredReceivers = true
}
} else {
logger.w(TAG, PERMISSION_ERROR_MESSAGE)
context.registerReceiver(
this,
IntentFilter(ACTION_AUDIO_STATE_CHANGED),
)
context.registerReceiver(
this,
IntentFilter(ACTION_SCO_AUDIO_STATE_UPDATED),
)
hasRegisteredReceivers = true
}
}

fun stop() {
if (hasPermissions()) {
headsetListener = null
bluetoothAdapter.closeProfileProxy(BluetoothProfile.HEADSET, headsetProxy)
if (hasRegisteredReceivers) {
context.unregisterReceiver(this)
hasRegisteredReceivers = false
}
} else {
logger.w(TAG, PERMISSION_ERROR_MESSAGE)
headsetListener = null
bluetoothAdapter.closeProfileProxy(BluetoothProfile.HEADSET, headsetProxy)
if (hasRegisteredReceivers) {
context.unregisterReceiver(this)
hasRegisteredReceivers = false
}
}

fun activate() {
if (hasPermissions()) {
if (headsetState == Connected || headsetState == AudioActivationError) {
enableBluetoothScoJob.executeBluetoothScoJob()
} else {
logger.w(TAG, "Cannot activate when in the ${headsetState::class.simpleName} state")
}
if (headsetState == Connected || headsetState == AudioActivationError) {
enableBluetoothScoJob.executeBluetoothScoJob()
} else {
logger.w(TAG, PERMISSION_ERROR_MESSAGE)
logger.w(TAG, "Cannot activate when in the ${headsetState::class.simpleName} state")
}
}

Expand All @@ -262,26 +246,16 @@ internal constructor(
}

fun hasActivationError(): Boolean {
return if (hasPermissions()) {
headsetState == AudioActivationError
} else {
logger.w(TAG, PERMISSION_ERROR_MESSAGE)
false
}
return headsetState == AudioActivationError
}

// TODO Remove bluetoothHeadsetName param
fun getHeadset(bluetoothHeadsetName: String?): AudioDevice.BluetoothHeadset? {
return if (hasPermissions()) {
if (headsetState != Disconnected) {
val headsetName = bluetoothHeadsetName ?: getHeadsetName()
headsetName?.let { AudioDevice.BluetoothHeadset(it) }
?: AudioDevice.BluetoothHeadset()
} else {
null
}
return if (headsetState != Disconnected) {
val headsetName = bluetoothHeadsetName ?: getHeadsetName()
headsetName?.let { AudioDevice.BluetoothHeadset(it) }
?: AudioDevice.BluetoothHeadset()
} else {
logger.w(TAG, PERMISSION_ERROR_MESSAGE)
null
}
}
Expand Down Expand Up @@ -309,6 +283,7 @@ internal constructor(

private fun hasActiveHeadsetChanged() = headsetState == AudioActivated && hasConnectedDevice() && !hasActiveHeadset()

@SuppressLint("MissingPermission")
private fun getHeadsetName(): String? =
headsetProxy?.let { proxy ->
proxy.connectedDevices?.let { devices ->
Expand All @@ -331,13 +306,15 @@ internal constructor(
}
}

@SuppressLint("MissingPermission")
private fun hasActiveHeadset() =
headsetProxy?.let { proxy ->
proxy.connectedDevices?.let { devices ->
devices.any { proxy.isAudioConnected(it) }
}
} ?: false

@SuppressLint("MissingPermission")
private fun hasConnectedDevice() =
headsetProxy?.let { proxy ->
proxy.connectedDevices?.let { devices ->
Expand All @@ -359,8 +336,6 @@ internal constructor(
deviceClass == BluetoothClass.Device.Major.UNCATEGORIZED
} ?: false

internal fun hasPermissions() = permissionsRequestStrategy.hasPermissions()

@VisibleForTesting(otherwise = VisibleForTesting.PRIVATE)
internal sealed class HeadsetState {
object Disconnected : HeadsetState()
Expand Down Expand Up @@ -408,27 +383,4 @@ internal constructor(
headsetState = AudioActivationError
}
}

internal class DefaultPermissionsCheckStrategy(private val context: Context) :
PermissionsCheckStrategy {
@SuppressLint("NewApi")
override fun hasPermissions(): Boolean {
return if (context.applicationInfo.targetSdkVersion <= android.os.Build.VERSION_CODES.R ||
android.os.Build.VERSION.SDK_INT <= android.os.Build.VERSION_CODES.R
) {
PERMISSION_GRANTED == context.checkPermission(
Manifest.permission.BLUETOOTH,
android.os.Process.myPid(),
android.os.Process.myUid(),
)
} else {
// for android 12/S or newer
PERMISSION_GRANTED == context.checkPermission(
Manifest.permission.BLUETOOTH_CONNECT,
android.os.Process.myPid(),
android.os.Process.myUid(),
)
}
}
}
}
Loading

0 comments on commit cd8794a

Please sign in to comment.